From 1d8948e2f75db9825e4893cd876d1aa4d1e96d4d Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Sat, 16 May 2026 14:55:06 +0200 Subject: [PATCH 1/6] Add live Azure DevOps test result publishing Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Directory.Packages.props | 1 + .../AzureDevOpsCommandLineOptions.cs | 2 + .../AzureDevOpsCommandLineProvider.cs | 12 +- .../AzureDevOpsExtensions.cs | 20 +- .../AzureDevOpsLivePublishingModels.cs | 54 ++ .../AzureDevOpsRunIdCoordinator.cs | 414 +++++++++++++ .../AzureDevOpsTestResultsClient.cs | 274 +++++++++ .../AzureDevOpsTestResultsPublisher.cs | 419 +++++++++++++ .../IAzureDevOpsTestResultsClient.cs | 13 + ...esting.Extensions.AzureDevOpsReport.csproj | 4 + .../Resources/AzureDevOpsResources.resx | 55 +- .../Resources/xlf/AzureDevOpsResources.cs.xlf | 88 ++- .../Resources/xlf/AzureDevOpsResources.de.xlf | 88 ++- .../Resources/xlf/AzureDevOpsResources.es.xlf | 88 ++- .../Resources/xlf/AzureDevOpsResources.fr.xlf | 88 ++- .../Resources/xlf/AzureDevOpsResources.it.xlf | 88 ++- .../Resources/xlf/AzureDevOpsResources.ja.xlf | 88 ++- .../Resources/xlf/AzureDevOpsResources.ko.xlf | 88 ++- .../Resources/xlf/AzureDevOpsResources.pl.xlf | 88 ++- .../xlf/AzureDevOpsResources.pt-BR.xlf | 88 ++- .../Resources/xlf/AzureDevOpsResources.ru.xlf | 88 ++- .../Resources/xlf/AzureDevOpsResources.tr.xlf | 88 ++- .../xlf/AzureDevOpsResources.zh-Hans.xlf | 88 ++- .../xlf/AzureDevOpsResources.zh-Hant.xlf | 88 ++- .../HelpInfoAllExtensionsTests.cs | 29 + .../AzureDevOpsLivePublishingTests.cs | 577 ++++++++++++++++++ 26 files changed, 2962 insertions(+), 56 deletions(-) create mode 100644 src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsLivePublishingModels.cs create mode 100644 src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsRunIdCoordinator.cs create mode 100644 src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsTestResultsClient.cs create mode 100644 src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsTestResultsPublisher.cs create mode 100644 src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/IAzureDevOpsTestResultsClient.cs create mode 100644 test/UnitTests/Microsoft.Testing.Extensions.UnitTests/AzureDevOpsLivePublishingTests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 25e873c44f..00779f72ca 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -59,6 +59,7 @@ + diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsCommandLineOptions.cs b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsCommandLineOptions.cs index bfe0b50025..e4723f9b22 100644 --- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsCommandLineOptions.cs +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsCommandLineOptions.cs @@ -7,4 +7,6 @@ internal static class AzureDevOpsCommandLineOptions { public const string AzureDevOpsOptionName = "report-azdo"; public const string AzureDevOpsReportSeverity = "report-azdo-severity"; + public const string PublishAzureDevOpsRunNameOptionName = "publish-azdo-run-name"; + public const string PublishAzureDevOpsTestResultsOptionName = "publish-azdo-test-results"; } diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsCommandLineProvider.cs b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsCommandLineProvider.cs index 85598bc581..b1e162ac8b 100644 --- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsCommandLineProvider.cs +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsCommandLineProvider.cs @@ -27,6 +27,8 @@ public IReadOnlyCollection GetCommandLineOptions() [ new CommandLineOption(AzureDevOpsCommandLineOptions.AzureDevOpsOptionName, AzureDevOpsResources.OptionDescription, ArgumentArity.Zero, false), new CommandLineOption(AzureDevOpsCommandLineOptions.AzureDevOpsReportSeverity, AzureDevOpsResources.SeverityOptionDescription, ArgumentArity.ExactlyOne, false), + new CommandLineOption(AzureDevOpsCommandLineOptions.PublishAzureDevOpsRunNameOptionName, AzureDevOpsResources.PublishAzdoRunNameOptionDescription, ArgumentArity.ExactlyOne, false), + new CommandLineOption(AzureDevOpsCommandLineOptions.PublishAzureDevOpsTestResultsOptionName, AzureDevOpsResources.PublishAzdoTestResultsOptionDescription, ArgumentArity.Zero, false), ]; public Task ValidateOptionArgumentsAsync(CommandLineOption commandOption, string[] arguments) @@ -51,6 +53,14 @@ public Task ValidateCommandLineOptionsAsync(ICommandLineOption return ValidationResult.InvalidTask(AzureDevOpsResources.AzureDevOpsReportSeverityRequiresAzureDevOps); } - return ValidationResult.ValidTask; + if (!commandLineOptions.IsOptionSet(AzureDevOpsCommandLineOptions.PublishAzureDevOpsRunNameOptionName)) + { + return ValidationResult.ValidTask; + } + + bool isPublishResultsEnabled = commandLineOptions.IsOptionSet(AzureDevOpsCommandLineOptions.PublishAzureDevOpsTestResultsOptionName); + return isPublishResultsEnabled + ? ValidationResult.ValidTask + : ValidationResult.InvalidTask(AzureDevOpsResources.PublishAzdoRunNameRequiresPublishAzdoTestResults); } } diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsExtensions.cs b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsExtensions.cs index 728a257520..6b77aeadf3 100644 --- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsExtensions.cs +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsExtensions.cs @@ -15,13 +15,13 @@ namespace Microsoft.Testing.Extensions; public static class AzureDevOpsExtensions { /// - /// Adds support to the test application builder. + /// Adds support to the test application builder. /// /// The test application builder. public static void AddAzureDevOpsProvider(this ITestApplicationBuilder builder) { var compositeTestSessionAzDoService = - new CompositeExtensionFactory(serviceProvider => + new CompositeExtensionFactory(serviceProvider => new AzureDevOpsReporter( serviceProvider.GetCommandLineOptions(), serviceProvider.GetEnvironment(), @@ -29,7 +29,23 @@ public static void AddAzureDevOpsProvider(this ITestApplicationBuilder builder) serviceProvider.GetOutputDevice(), serviceProvider.GetLoggerFactory())); + var compositeTestResultsPublisher = + new CompositeExtensionFactory(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(compositeTestSessionAzDoService); + builder.TestHost.AddDataConsumer(compositeTestResultsPublisher); + builder.TestHost.AddTestSessionLifetimeHandler(compositeTestResultsPublisher); builder.CommandLine.AddProvider(() => new AzureDevOpsCommandLineProvider()); } diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsLivePublishingModels.cs b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsLivePublishingModels.cs new file mode 100644 index 0000000000..9815507a10 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsLivePublishingModels.cs @@ -0,0 +1,54 @@ +// 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) +{ + public AzureDevOpsTestResultsPublisherOptions(int batchSize, TimeSpan flushInterval, int coordinationReadRetryCount, TimeSpan coordinationReadRetryDelay) + : this(batchSize, flushInterval, coordinationReadRetryCount, coordinationReadRetryDelay, TimeSpan.FromSeconds(30), TimeSpan.FromHours(4)) + { + } + + public static AzureDevOpsTestResultsPublisherOptions Default { get; } = new(100, TimeSpan.FromSeconds(5), 40, TimeSpan.FromMilliseconds(250), TimeSpan.FromSeconds(30), TimeSpan.FromHours(4)); +} diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsRunIdCoordinator.cs b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsRunIdCoordinator.cs new file mode 100644 index 0000000000..3269b701ca --- /dev/null +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsRunIdCoordinator.cs @@ -0,0 +1,414 @@ +// 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; +using System.Text.Json.Serialization; + +using Microsoft.Testing.Extensions.AzureDevOpsReport.Resources; +using Microsoft.Testing.Platform.Helpers; +using Microsoft.Testing.Platform.Logging; + +namespace Microsoft.Testing.Extensions.AzureDevOpsReport; + +internal sealed class AzureDevOpsRunIdCoordinator +{ + private const string CoordinationFilePrefix = "azdo-runid"; + private static readonly JsonSerializerOptions JsonSerializerOptions = new() + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + + private static readonly UTF8Encoding Utf8EncodingWithoutBom = new(encoderShouldEmitUTF8Identifier: false); + + private readonly IFileSystem _fileSystem; + private readonly ITask _task; + private readonly IClock _clock; + private readonly IEnvironment _environment; + private readonly ILogger _logger; + private readonly AzureDevOpsTestResultsPublisherOptions _options; + + public AzureDevOpsRunIdCoordinator(IFileSystem fileSystem, ITask task, IClock clock, IEnvironment environment, ILogger logger, AzureDevOpsTestResultsPublisherOptions options) + { + _fileSystem = fileSystem; + _task = task; + _clock = clock; + _environment = environment; + _logger = logger; + _options = options; + } + + public async Task AcquireRunAsync(AzureDevOpsPublishConfiguration configuration, Func> createRunAsync, CancellationToken cancellationToken) + { + _fileSystem.CreateDirectory(configuration.ResultsDirectory); + + string runIdFilePath = Path.Combine(configuration.ResultsDirectory, GetRunIdFileName(configuration.BuildId)); + string ownerFilePath = Path.Combine(configuration.ResultsDirectory, GetOwnerFileName(configuration.BuildId)); + string participantFilePath = Path.Combine(configuration.ResultsDirectory, GetParticipantFileName(configuration.BuildId, _environment.ProcessId)); + bool ownsOwnerFile = false; + + try + { + await WriteParticipantLeaseAsync(participantFilePath, configuration.BuildId, cancellationToken).ConfigureAwait(false); + + ownsOwnerFile = await TryAcquireOwnerAsync(ownerFilePath, configuration.BuildId, cancellationToken).ConfigureAwait(false); + if (ownsOwnerFile) + { + int runId = await createRunAsync(cancellationToken).ConfigureAwait(false); + await WriteRunIdFileAsync(runIdFilePath, configuration, runId, cancellationToken).ConfigureAwait(false); + return new AzureDevOpsCoordinatedRun(runId, true, configuration.BuildId, configuration.ResultsDirectory, runIdFilePath, ownerFilePath, participantFilePath); + } + + AzureDevOpsRunIdFile? runIdFile = await WaitForRunIdFileAsync(runIdFilePath, configuration.BuildId, cancellationToken).ConfigureAwait(false); + if (runIdFile is null) + { + ownsOwnerFile = await TryAcquireOwnerAsync(ownerFilePath, configuration.BuildId, cancellationToken).ConfigureAwait(false); + if (ownsOwnerFile) + { + int runId = await createRunAsync(cancellationToken).ConfigureAwait(false); + await WriteRunIdFileAsync(runIdFilePath, configuration, runId, cancellationToken).ConfigureAwait(false); + return new AzureDevOpsCoordinatedRun(runId, true, configuration.BuildId, configuration.ResultsDirectory, runIdFilePath, ownerFilePath, participantFilePath); + } + + runIdFile = await WaitForRunIdFileAsync(runIdFilePath, configuration.BuildId, cancellationToken).ConfigureAwait(false); + } + + if (runIdFile is not null + && string.Equals(runIdFile.CollectionUri, configuration.CollectionUri, StringComparison.OrdinalIgnoreCase) + && string.Equals(runIdFile.Project, configuration.Project, StringComparison.Ordinal)) + { + return new AzureDevOpsCoordinatedRun(runIdFile.RunId, false, configuration.BuildId, configuration.ResultsDirectory, runIdFilePath, ownerFilePath, participantFilePath); + } + + if (runIdFile is not null) + { + throw new InvalidOperationException(AzureDevOpsResources.AzureDevOpsLivePublishingRunIdFileMismatch); + } + + // TODO: Elect a deterministic surviving participant when the owner lease expires before writing azdo-runid..json. + throw new InvalidOperationException(AzureDevOpsResources.AzureDevOpsLivePublishingMissingRunIdFile); + } + catch + { + TryDeleteFile(participantFilePath); + TryDeleteFile(runIdFilePath); + TryDeleteFile(ownerFilePath); + throw; + } + } + + public async Task RenewLeaseAsync(AzureDevOpsCoordinatedRun coordinatedRun, CancellationToken cancellationToken) + { + await WriteLeaseFileAsync(coordinatedRun.ParticipantFilePath, CreateLeaseFile(coordinatedRun.BuildId), overwrite: true, cancellationToken).ConfigureAwait(false); + + if (coordinatedRun.IsOwner) + { + await WriteLeaseFileAsync(coordinatedRun.OwnerFilePath, CreateLeaseFile(coordinatedRun.BuildId), overwrite: true, cancellationToken).ConfigureAwait(false); + } + } + + public async Task FinalizeRunAsync(AzureDevOpsCoordinatedRun coordinatedRun, Func finalizeRunAsync, CancellationToken cancellationToken) + { + TryDeleteFile(coordinatedRun.ParticipantFilePath); + + if (!coordinatedRun.IsOwner) + { + return; + } + + DateTimeOffset timeoutAt = _clock.UtcNow + _options.CoordinationFinalizeTimeout; + + while (true) + { + string[] participantFiles = _fileSystem.GetFiles(coordinatedRun.ResultsDirectory, GetParticipantSearchPattern(coordinatedRun.BuildId), SearchOption.TopDirectoryOnly); + participantFiles = CleanupStaleParticipants(participantFiles); + + if (participantFiles.Length == 0) + { + break; + } + + if (_clock.UtcNow >= timeoutAt) + { + _logger.LogWarning(string.Format(CultureInfo.InvariantCulture, AzureDevOpsResources.AzureDevOpsLivePublishingFinalizeWaitTimedOut, _options.CoordinationFinalizeTimeout, participantFiles.Length)); + break; + } + + await _task.Delay(_options.CoordinationReadRetryDelay, cancellationToken).ConfigureAwait(false); + } + + try + { + await finalizeRunAsync(cancellationToken).ConfigureAwait(false); + } + finally + { + TryDeleteFile(coordinatedRun.OwnerFilePath); + TryDeleteFile(coordinatedRun.RunIdFilePath); + } + } + + private string[] CleanupStaleParticipants(string[] participantFiles) + { + var activeParticipants = new List(); + + foreach (string participantFile in participantFiles) + { + AzureDevOpsLeaseFile? lease = TryReadLeaseFile(participantFile); + if (lease is not null) + { + if (lease.ExpiresAt > _clock.UtcNow && IsProcessAlive(lease.ProcessId)) + { + activeParticipants.Add(participantFile); + continue; + } + } + else if (TryGetPid(participantFile) is int processId && IsProcessAlive(processId)) + { + activeParticipants.Add(participantFile); + continue; + } + + TryDeleteFile(participantFile); + } + + return [.. activeParticipants]; + } + + private static bool IsProcessAlive(int processId) + { + try + { + using var process = Process.GetProcessById(processId); + return !process.HasExited; + } + catch (ArgumentException) + { + return false; + } + catch (InvalidOperationException) + { + return false; + } + } + + [UnconditionalSuppressMessage("ReflectionAnalysis", "IL2026", Justification = "The coordination payload type is internal, fixed, and controlled by this extension.")] + [UnconditionalSuppressMessage("Aot", "IL3050", Justification = "The coordination payload type is internal, fixed, and controlled by this extension.")] + private async Task WaitForRunIdFileAsync(string runIdFilePath, int buildId, CancellationToken cancellationToken) + { + for (int attempt = 0; attempt < _options.CoordinationReadRetryCount; attempt++) + { + if (_fileSystem.ExistFile(runIdFilePath)) + { + try + { + string content = await _fileSystem.ReadAllTextAsync(runIdFilePath).ConfigureAwait(false); + AzureDevOpsRunIdFile? runIdFile = JsonSerializer.Deserialize(content, JsonSerializerOptions); + if (runIdFile is not null) + { + if (runIdFile.ExpiresAt <= _clock.UtcNow || runIdFile.BuildId != buildId) + { + TryDeleteFile(runIdFilePath); + } + else + { + return runIdFile; + } + } + } + catch (IOException) + { + } + catch (UnauthorizedAccessException) + { + } + catch (JsonException) + { + } + } + + await _task.Delay(_options.CoordinationReadRetryDelay, cancellationToken).ConfigureAwait(false); + } + + return null; + } + + [UnconditionalSuppressMessage("ReflectionAnalysis", "IL2026", Justification = "The coordination payload type is internal, fixed, and controlled by this extension.")] + [UnconditionalSuppressMessage("Aot", "IL3050", Justification = "The coordination payload type is internal, fixed, and controlled by this extension.")] + private async Task WriteParticipantLeaseAsync(string participantFilePath, int buildId, CancellationToken cancellationToken) + { + AzureDevOpsLeaseFile lease = CreateLeaseFile(buildId); + if (await TryWriteLeaseFileAsync(participantFilePath, lease, overwrite: false, cancellationToken).ConfigureAwait(false)) + { + return; + } + + await WriteLeaseFileAsync(participantFilePath, lease, overwrite: true, cancellationToken).ConfigureAwait(false); + } + + [UnconditionalSuppressMessage("ReflectionAnalysis", "IL2026", Justification = "The coordination payload type is internal, fixed, and controlled by this extension.")] + [UnconditionalSuppressMessage("Aot", "IL3050", Justification = "The coordination payload type is internal, fixed, and controlled by this extension.")] + private async Task TryAcquireOwnerAsync(string ownerFilePath, int buildId, CancellationToken cancellationToken) + { + AzureDevOpsLeaseFile lease = CreateLeaseFile(buildId); + if (await TryWriteLeaseFileAsync(ownerFilePath, lease, overwrite: false, cancellationToken).ConfigureAwait(false)) + { + return true; + } + + AzureDevOpsLeaseFile? existingLease = TryReadLeaseFile(ownerFilePath); + if (existingLease is null || existingLease.ExpiresAt <= _clock.UtcNow) + { + TryDeleteFile(ownerFilePath); + return await TryWriteLeaseFileAsync(ownerFilePath, lease, overwrite: false, cancellationToken).ConfigureAwait(false); + } + + return false; + } + + private AzureDevOpsLeaseFile CreateLeaseFile(int buildId) + => new(_environment.ProcessId, buildId, _clock.UtcNow + _options.CoordinationFileExpiration); + + [UnconditionalSuppressMessage("ReflectionAnalysis", "IL2026", Justification = "The coordination payload type is internal, fixed, and controlled by this extension.")] + [UnconditionalSuppressMessage("Aot", "IL3050", Justification = "The coordination payload type is internal, fixed, and controlled by this extension.")] + private AzureDevOpsLeaseFile? TryReadLeaseFile(string path) + { + if (!_fileSystem.ExistFile(path)) + { + return null; + } + + try + { + string content = _fileSystem.ReadAllText(path); + AzureDevOpsLeaseFile? lease = JsonSerializer.Deserialize(content, JsonSerializerOptions); + if (lease is not null) + { + return lease; + } + + if (int.TryParse(content, NumberStyles.Integer, CultureInfo.InvariantCulture, out int processId)) + { + return new AzureDevOpsLeaseFile(processId, 0, DateTimeOffset.MinValue); + } + } + catch (IOException) + { + } + catch (UnauthorizedAccessException) + { + } + catch (JsonException) + { + } + + return null; + } + + [UnconditionalSuppressMessage("ReflectionAnalysis", "IL2026", Justification = "The coordination payload type is internal, fixed, and controlled by this extension.")] + [UnconditionalSuppressMessage("Aot", "IL3050", Justification = "The coordination payload type is internal, fixed, and controlled by this extension.")] + private async Task WriteRunIdFileAsync(string runIdFilePath, AzureDevOpsPublishConfiguration configuration, int runId, CancellationToken cancellationToken) + => await WriteJsonFileAsync(runIdFilePath, new AzureDevOpsRunIdFile(runId, configuration.BuildId, configuration.CollectionUri, configuration.Project, _clock.UtcNow + _options.CoordinationFileExpiration), overwrite: true, cancellationToken).ConfigureAwait(false); + + [UnconditionalSuppressMessage("ReflectionAnalysis", "IL2026", Justification = "The coordination payload type is internal, fixed, and controlled by this extension.")] + [UnconditionalSuppressMessage("Aot", "IL3050", Justification = "The coordination payload type is internal, fixed, and controlled by this extension.")] + private Task WriteLeaseFileAsync(string path, AzureDevOpsLeaseFile payload, bool overwrite, CancellationToken cancellationToken) + => WriteJsonFileAsync(path, payload, overwrite, cancellationToken); + + [UnconditionalSuppressMessage("ReflectionAnalysis", "IL2026", Justification = "The coordination payload type is internal, fixed, and controlled by this extension.")] + [UnconditionalSuppressMessage("Aot", "IL3050", Justification = "The coordination payload type is internal, fixed, and controlled by this extension.")] + private async Task TryWriteLeaseFileAsync(string path, AzureDevOpsLeaseFile payload, bool overwrite, CancellationToken cancellationToken) + { + try + { + await WriteLeaseFileAsync(path, payload, overwrite, cancellationToken).ConfigureAwait(false); + return true; + } + catch (IOException) + { + return false; + } + catch (UnauthorizedAccessException) + { + return false; + } + } + + [UnconditionalSuppressMessage("ReflectionAnalysis", "IL2026", Justification = "The coordination payload type is internal, fixed, and controlled by this extension.")] + [UnconditionalSuppressMessage("Aot", "IL3050", Justification = "The coordination payload type is internal, fixed, and controlled by this extension.")] + private async Task WriteJsonFileAsync(string path, TPayload payload, bool overwrite, CancellationToken cancellationToken) + { + string json = JsonSerializer.Serialize(payload, JsonSerializerOptions); + using IFileStream stream = _fileSystem.NewFileStream(path, overwrite ? FileMode.Create : FileMode.CreateNew, FileAccess.Write, FileShare.Read); + using StreamWriter writer = new(stream.Stream, Utf8EncodingWithoutBom, 1024, leaveOpen: true); +#if NET + await writer.WriteAsync(json.AsMemory(), cancellationToken).ConfigureAwait(false); + await writer.FlushAsync(cancellationToken).ConfigureAwait(false); +#else + await writer.WriteAsync(json).ConfigureAwait(false); + await writer.FlushAsync().ConfigureAwait(false); +#endif + } + + private void TryDeleteFile(string path) + { + try + { + if (_fileSystem.ExistFile(path)) + { + _fileSystem.DeleteFile(path); + } + } + catch (IOException ex) + { + _logger.LogWarning($"{AzureDevOpsResources.AzureDevOpsLivePublishingFailedToDeleteCoordinationFile} {path}: {ex.Message}"); + } + catch (UnauthorizedAccessException ex) + { + _logger.LogWarning($"{AzureDevOpsResources.AzureDevOpsLivePublishingFailedToDeleteCoordinationFile} {path}: {ex.Message}"); + } + } + + private static int? TryGetPid(string participantFile) + { + string fileName = Path.GetFileNameWithoutExtension(participantFile); + int lastSeparator = fileName.LastIndexOf('.'); + return lastSeparator < 0 + ? null + : int.TryParse(fileName[(lastSeparator + 1)..], NumberStyles.Integer, CultureInfo.InvariantCulture, out int processId) + ? processId + : null; + } + + private static string GetOwnerFileName(int buildId) + => $"{CoordinationFilePrefix}.{buildId}.owner"; + + private static string GetParticipantFileName(int buildId, int processId) + => $"{CoordinationFilePrefix}.{buildId}.participant.{processId}.json"; + + private static string GetParticipantSearchPattern(int buildId) + => $"{CoordinationFilePrefix}.{buildId}.participant.*.json"; + + private static string GetRunIdFileName(int buildId) + => $"{CoordinationFilePrefix}.{buildId}.json"; +} + +internal sealed record AzureDevOpsCoordinatedRun( + int RunId, + bool IsOwner, + int BuildId, + string ResultsDirectory, + string RunIdFilePath, + string OwnerFilePath, + string ParticipantFilePath); + +internal sealed record AzureDevOpsRunIdFile( + [property: JsonPropertyName("runId")] int RunId, + [property: JsonPropertyName("buildId")] int BuildId, + [property: JsonPropertyName("collectionUri")] string CollectionUri, + [property: JsonPropertyName("project")] string Project, + [property: JsonPropertyName("expiresAt")] DateTimeOffset ExpiresAt); + +internal sealed record AzureDevOpsLeaseFile( + [property: JsonPropertyName("processId")] int ProcessId, + [property: JsonPropertyName("buildId")] int BuildId, + [property: JsonPropertyName("expiresAt")] DateTimeOffset ExpiresAt); diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsTestResultsClient.cs b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsTestResultsClient.cs new file mode 100644 index 0000000000..8b52162607 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsTestResultsClient.cs @@ -0,0 +1,274 @@ +// 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; +using System.Net.Http.Headers; +using System.Net.Sockets; +using System.Text.Json; +using System.Text.Json.Serialization; + +using Microsoft.Testing.Extensions.AzureDevOpsReport.Resources; +using Microsoft.Testing.Platform.Helpers; + +namespace Microsoft.Testing.Extensions.AzureDevOpsReport; + +internal sealed class AzureDevOpsTestResultsClient : IAzureDevOpsTestResultsClient +{ + private const string ApiVersion = "7.1"; + private const int MaxAttempts = 3; + private const int BaseDelayMilliseconds = 500; + private static readonly TimeSpan AttemptTimeout = TimeSpan.FromSeconds(15); + private static readonly TimeSpan HttpClientTimeout = TimeSpan.FromSeconds(30); + private static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(60); + private static readonly HttpMethod PatchMethod = new("PATCH"); + private static readonly JsonSerializerOptions JsonSerializerOptions = new() + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + + private static readonly HttpClient SharedHttpClient = CreateHttpClient(); + + private readonly HttpClient _httpClient; + private readonly ITask _task; + private readonly IClock _clock; + + public AzureDevOpsTestResultsClient(ITask task, IClock clock) + : this(SharedHttpClient, task, clock) + { + } + + internal AzureDevOpsTestResultsClient(HttpClient httpClient, ITask task, IClock clock) + { + _httpClient = httpClient; + _task = task; + _clock = clock; + } + + public async Task CreateTestRunAsync(AzureDevOpsPublishConfiguration configuration, CancellationToken cancellationToken) + { + using HttpRequestMessage request = CreateRequest( + HttpMethod.Post, + BuildRunsUri(configuration.CollectionUri, configuration.Project), + configuration.AccessToken, + new CreateTestRunRequest(configuration.RunName, true, new BuildReference(configuration.BuildId), AzureDevOpsLivePublishingConstants.InProgressTestRunState)); + + CreateTestRunResponse response = await SendAsync(request, cancellationToken).ConfigureAwait(false); + return response.Id; + } + + public Task PublishTestResultsAsync(AzureDevOpsPublishConfiguration configuration, int runId, IReadOnlyList results, CancellationToken cancellationToken) + { + using HttpRequestMessage request = CreateRequest( + HttpMethod.Post, + BuildResultsUri(configuration.CollectionUri, configuration.Project, runId), + configuration.AccessToken, + results); + + return SendAsync(request, cancellationToken); + } + + public Task UpdateTestRunStateAsync(AzureDevOpsPublishConfiguration configuration, int runId, string state, CancellationToken cancellationToken) + { + using HttpRequestMessage request = CreateRequest( + PatchMethod, + BuildRunUri(configuration.CollectionUri, configuration.Project, runId), + configuration.AccessToken, + new UpdateTestRunStateRequest(state)); + + return SendAsync(request, cancellationToken); + } + + private static HttpClient CreateHttpClient() + { + HttpClientHandler handler = new() + { + AutomaticDecompression = DecompressionMethods.Deflate | DecompressionMethods.GZip, + }; + + return new HttpClient(handler, disposeHandler: false) + { + Timeout = HttpClientTimeout, + }; + } + + private static Uri BuildRunsUri(string collectionUri, string project) + => new(new Uri(collectionUri, UriKind.Absolute), $"{Uri.EscapeDataString(project)}/_apis/test/runs?api-version={ApiVersion}"); + + private static Uri BuildRunUri(string collectionUri, string project, int runId) + => new(new Uri(collectionUri, UriKind.Absolute), $"{Uri.EscapeDataString(project)}/_apis/test/runs/{runId}?api-version={ApiVersion}"); + + 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}"); + + [UnconditionalSuppressMessage("ReflectionAnalysis", "IL2026", Justification = "Payload types are internal, fixed, and controlled by this extension.")] + [UnconditionalSuppressMessage("Aot", "IL3050", Justification = "Payload types are internal, fixed, and controlled by this extension.")] + private static HttpRequestMessage CreateRequest(HttpMethod method, Uri uri, string accessToken, TPayload payload) + { + HttpRequestMessage request = CreateRequest(method, uri, accessToken); + request.Content = new StringContent(JsonSerializer.Serialize(payload, JsonSerializerOptions), Encoding.UTF8, "application/json"); + return request; + } + + private static HttpRequestMessage CreateRequest(HttpMethod method, Uri uri, string accessToken) + { + HttpRequestMessage request = new(method, uri); + request.Headers.Authorization = new AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.UTF8.GetBytes($":{accessToken}"))); + request.Headers.Accept.ParseAdd($"application/json; api-version={ApiVersion}"); + return request; + } + + [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 async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + using var requestTimeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + requestTimeoutSource.CancelAfter(RequestTimeout); + + using HttpResponseMessage response = await SendCoreAsync(request, requestTimeoutSource.Token, cancellationToken).ConfigureAwait(false); + string payload = await ReadAsStringAsync(response.Content, requestTimeoutSource.Token).ConfigureAwait(false); + TResponse? deserialized = JsonSerializer.Deserialize(payload, JsonSerializerOptions); + return deserialized ?? throw new InvalidOperationException(AzureDevOpsResources.AzureDevOpsLivePublishingInvalidResponse); + } + + private async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + using var requestTimeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + requestTimeoutSource.CancelAfter(RequestTimeout); + using HttpResponseMessage ignoredResponse = await SendCoreAsync(request, requestTimeoutSource.Token, cancellationToken).ConfigureAwait(false); + } + + private async Task SendCoreAsync(HttpRequestMessage request, CancellationToken requestCancellationToken, CancellationToken userCancellationToken) + { + Exception? lastException = null; + + for (int attempt = 1; attempt <= MaxAttempts; attempt++) + { + using HttpRequestMessage currentRequest = await CloneAsync(request, requestCancellationToken).ConfigureAwait(false); + using var attemptTimeoutSource = CancellationTokenSource.CreateLinkedTokenSource(requestCancellationToken); + attemptTimeoutSource.CancelAfter(AttemptTimeout); + + try + { + HttpResponseMessage response = await _httpClient.SendAsync(currentRequest, attemptTimeoutSource.Token).ConfigureAwait(false); + if (response.IsSuccessStatusCode) + { + return response; + } + + if (!ShouldRetry(response.StatusCode, attempt)) + { + 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)) + { + lastException = ex; + await _task.Delay(GetExponentialBackoffDelay(attempt), requestCancellationToken).ConfigureAwait(false); + } + } + + throw new InvalidOperationException(AzureDevOpsResources.AzureDevOpsLivePublishingRequestFailed, lastException); + } + + private static bool ShouldRetry(HttpStatusCode statusCode, int attempt) + => attempt < MaxAttempts && ((int)statusCode is >= 500 or 429); + + private static bool ShouldRetry(Exception exception, CancellationToken userCancellationToken, CancellationToken requestCancellationToken, int attempt) + => attempt < MaxAttempts + && !userCancellationToken.IsCancellationRequested + && !requestCancellationToken.IsCancellationRequested + && exception is HttpRequestException or IOException or SocketException or TaskCanceledException; + + private TimeSpan GetDelay(HttpResponseMessage response, int attempt) + { + if (response.StatusCode == (HttpStatusCode)429 && response.Headers.RetryAfter is { } retryAfter) + { + if (retryAfter.Delta is { } delta && delta > TimeSpan.Zero) + { + return delta; + } + + if (retryAfter.Date is { } date) + { + TimeSpan delay = date - _clock.UtcNow; + if (delay > TimeSpan.Zero) + { + return delay; + } + } + } + + return GetExponentialBackoffDelay(attempt); + } + + private static TimeSpan GetExponentialBackoffDelay(int attempt) + => TimeSpan.FromMilliseconds(BaseDelayMilliseconds * Math.Pow(2, attempt - 1)); + + private static async Task CloneAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + HttpRequestMessage clone = new(request.Method, request.RequestUri) + { + Version = request.Version, +#if NET + VersionPolicy = request.VersionPolicy, +#endif + }; + +#if NET + foreach (KeyValuePair option in request.Options) + { + clone.Options.Set(new HttpRequestOptionsKey(option.Key), option.Value); + } +#endif + + foreach (KeyValuePair> header in request.Headers) + { + clone.Headers.TryAddWithoutValidation(header.Key, header.Value); + } + + if (request.Content is not null) + { + byte[] payload = await ReadAsByteArrayAsync(request.Content, cancellationToken).ConfigureAwait(false); + clone.Content = new ByteArrayContent(payload); + + foreach (KeyValuePair> header in request.Content.Headers) + { + clone.Content.Headers.TryAddWithoutValidation(header.Key, header.Value); + } + } + + return clone; + } + + private static Task ReadAsByteArrayAsync(HttpContent content, CancellationToken cancellationToken) +#if NET + => content.ReadAsByteArrayAsync(cancellationToken); +#else + => content.ReadAsByteArrayAsync(); +#endif + + private static Task ReadAsStringAsync(HttpContent content, CancellationToken cancellationToken) +#if NET + => content.ReadAsStringAsync(cancellationToken); +#else + => content.ReadAsStringAsync(); +#endif + + private sealed record CreateTestRunRequest( + [property: JsonPropertyName("name")] string Name, + [property: JsonPropertyName("automated")] bool Automated, + [property: JsonPropertyName("build")] BuildReference Build, + [property: JsonPropertyName("state")] string State); + + private sealed record BuildReference([property: JsonPropertyName("id")] int Id); + + private sealed record CreateTestRunResponse([property: JsonPropertyName("id")] int Id); + + private sealed record UpdateTestRunStateRequest([property: JsonPropertyName("state")] string State); +} diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsTestResultsPublisher.cs b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsTestResultsPublisher.cs new file mode 100644 index 0000000000..82837eeb93 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsTestResultsPublisher.cs @@ -0,0 +1,419 @@ +// 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; +using Microsoft.Testing.Extensions.Reporting; +using Microsoft.Testing.Platform; +using Microsoft.Testing.Platform.CommandLine; +using Microsoft.Testing.Platform.Configurations; +using Microsoft.Testing.Platform.Extensions; +using Microsoft.Testing.Platform.Extensions.Messages; +using Microsoft.Testing.Platform.Extensions.TestHost; +using Microsoft.Testing.Platform.Helpers; +using Microsoft.Testing.Platform.Logging; +using Microsoft.Testing.Platform.OutputDevice; +using Microsoft.Testing.Platform.Services; + +namespace Microsoft.Testing.Extensions.AzureDevOpsReport; + +internal sealed class AzureDevOpsTestResultsPublisher : IDataConsumer, ITestSessionLifetimeHandler, IDisposable +{ + private readonly ICommandLineOptions _commandLineOptions; + private readonly IConfiguration _configuration; + private readonly IEnvironment _environment; + private readonly IFileSystem _fileSystem; + private readonly ITestApplicationModuleInfo _testApplicationModuleInfo; + private readonly ITestApplicationProcessExitCode _testApplicationProcessExitCode; + private readonly IAzureDevOpsTestResultsClient _client; + private readonly ITask _task; + private readonly IClock _clock; + private readonly ILogger _logger; + private readonly AzureDevOpsTestResultsPublisherOptions _options; + private readonly ConcurrentQueue _pendingResults = new(); + private readonly SemaphoreSlim _flushSemaphore = new(1, 1); + + private AzureDevOpsPublishConfiguration? _publishConfiguration; + private AzureDevOpsRunIdCoordinator? _runIdCoordinator; + private AzureDevOpsCoordinatedRun? _coordinatedRun; + private DateTimeOffset _lastFlushTime; + + private int? CurrentRunId { get; set; } + + public AzureDevOpsTestResultsPublisher( + ICommandLineOptions commandLineOptions, + IConfiguration configuration, + IEnvironment environment, + IFileSystem fileSystem, + ITestApplicationModuleInfo testApplicationModuleInfo, + ITestApplicationProcessExitCode testApplicationProcessExitCode, + IAzureDevOpsTestResultsClient client, + ITask task, + IClock clock, + ILoggerFactory loggerFactory) + : this(commandLineOptions, configuration, environment, fileSystem, testApplicationModuleInfo, testApplicationProcessExitCode, client, task, clock, loggerFactory.CreateLogger(), AzureDevOpsTestResultsPublisherOptions.Default) + { + } + + internal AzureDevOpsTestResultsPublisher( + ICommandLineOptions commandLineOptions, + IConfiguration configuration, + IEnvironment environment, + IFileSystem fileSystem, + ITestApplicationModuleInfo testApplicationModuleInfo, + ITestApplicationProcessExitCode testApplicationProcessExitCode, + IAzureDevOpsTestResultsClient client, + ITask task, + IClock clock, + ILogger logger, + AzureDevOpsTestResultsPublisherOptions options) + { + _commandLineOptions = commandLineOptions; + _configuration = configuration; + _environment = environment; + _fileSystem = fileSystem; + _testApplicationModuleInfo = testApplicationModuleInfo; + _testApplicationProcessExitCode = testApplicationProcessExitCode; + _client = client; + _task = task; + _clock = clock; + _logger = logger; + _options = options; + _lastFlushTime = clock.UtcNow; + } + + public Type[] DataTypesConsumed { get; } = [typeof(TestNodeUpdateMessage)]; + + public string Uid => nameof(AzureDevOpsTestResultsPublisher); + + public string Version => ExtensionVersion.DefaultSemVer; + + public string DisplayName => AzureDevOpsResources.DisplayName; + + public string Description => AzureDevOpsResources.Description; + + internal int? RunId => CurrentRunId; + + public void Dispose() + => _flushSemaphore.Dispose(); + + public Task IsEnabledAsync() + { + if (!_commandLineOptions.IsOptionSet(AzureDevOpsCommandLineOptions.PublishAzureDevOpsTestResultsOptionName)) + { + return Task.FromResult(false); + } + + if (_publishConfiguration is not null) + { + return Task.FromResult(true); + } + + if (!TryCreatePublishConfiguration(out AzureDevOpsPublishConfiguration? publishConfiguration, out string? warning)) + { + _logger.LogWarning(warning ?? AzureDevOpsResources.AzureDevOpsLivePublishingMissingConfiguration); + return Task.FromResult(false); + } + + _publishConfiguration = publishConfiguration; + _runIdCoordinator = new AzureDevOpsRunIdCoordinator(_fileSystem, _task, _clock, _environment, _logger, _options); + return Task.FromResult(true); + } + + public async Task OnTestSessionStartingAsync(ITestSessionContext testSessionContext) + { + if (_publishConfiguration is null || _runIdCoordinator is null) + { + return; + } + + try + { + _coordinatedRun = await _runIdCoordinator.AcquireRunAsync( + _publishConfiguration, + cancellationToken => _client.CreateTestRunAsync(_publishConfiguration, cancellationToken), + testSessionContext.CancellationToken).ConfigureAwait(false); + CurrentRunId = _coordinatedRun.RunId; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogWarning($"{AzureDevOpsResources.AzureDevOpsLivePublishingCreateRunFailed} {ex.Message}"); + _publishConfiguration = null; + _coordinatedRun = null; + CurrentRunId = null; + } + } + + public async Task ConsumeAsync(IDataProducer dataProducer, IData value, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (_publishConfiguration is null || _runIdCoordinator is null || _coordinatedRun is null || CurrentRunId is null || value is not TestNodeUpdateMessage testNodeUpdateMessage) + { + return; + } + + try + { + AzureDevOpsTestCaseResult? testCaseResult = CreateTestCaseResult(testNodeUpdateMessage.TestNode, _publishConfiguration.AutomatedTestStorage); + if (testCaseResult is null) + { + return; + } + + await _runIdCoordinator.RenewLeaseAsync(_coordinatedRun, cancellationToken).ConfigureAwait(false); + _pendingResults.Enqueue(testCaseResult); + await FlushPendingResultsAsync(force: false, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogWarning($"{AzureDevOpsResources.AzureDevOpsLivePublishingPublishResultsFailed} {ex.Message}"); + } + } + + public async Task OnTestSessionFinishingAsync(ITestSessionContext testSessionContext) + { + if (_publishConfiguration is null || _coordinatedRun is null || CurrentRunId is null || _runIdCoordinator is null) + { + return; + } + + try + { + await _runIdCoordinator.RenewLeaseAsync(_coordinatedRun, testSessionContext.CancellationToken).ConfigureAwait(false); + await FlushPendingResultsAsync(force: true, testSessionContext.CancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogWarning($"{AzureDevOpsResources.AzureDevOpsLivePublishingPublishResultsFailed} {ex.Message}"); + } + + string finalState = _testApplicationProcessExitCode.GetProcessExitCode() == 0 && !_testApplicationProcessExitCode.HasTestAdapterTestSessionFailure + ? AzureDevOpsLivePublishingConstants.CompletedTestRunState + : AzureDevOpsLivePublishingConstants.AbortedTestRunState; + + try + { + await _runIdCoordinator.FinalizeRunAsync( + _coordinatedRun, + cancellationToken => _client.UpdateTestRunStateAsync(_publishConfiguration, CurrentRunId.Value, finalState, cancellationToken), + testSessionContext.CancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogWarning($"{AzureDevOpsResources.AzureDevOpsLivePublishingCompleteRunFailed} {ex.Message}"); + } + } + + internal static AzureDevOpsTestCaseResult? CreateTestCaseResult(TestNode testNode, string automatedTestStorage) + { + TestNodeStateProperty? state = testNode.Properties.SingleOrDefault(); + if (state is null or DiscoveredTestNodeStateProperty or InProgressTestNodeStateProperty) + { + return null; + } + + TimingProperty? timing = testNode.Properties.SingleOrDefault(); + string automatedTestName = testNode.Uid.Value; + + return state switch + { + PassedTestNodeStateProperty passed => CreateResult(testNode.DisplayName, automatedTestName, automatedTestStorage, AzureDevOpsLivePublishingConstants.PassedTestOutcome, passed.Explanation, null, timing), + FailedTestNodeStateProperty failed => CreateResult(testNode.DisplayName, automatedTestName, automatedTestStorage, AzureDevOpsLivePublishingConstants.FailedTestOutcome, failed.Exception?.Message ?? failed.Explanation, failed.Exception?.StackTrace, timing), + ErrorTestNodeStateProperty error => CreateResult(testNode.DisplayName, automatedTestName, automatedTestStorage, AzureDevOpsLivePublishingConstants.FailedTestOutcome, error.Exception?.Message ?? error.Explanation, error.Exception?.StackTrace, timing), + SkippedTestNodeStateProperty skipped => CreateResult(testNode.DisplayName, automatedTestName, automatedTestStorage, AzureDevOpsLivePublishingConstants.NotExecutedTestOutcome, skipped.Explanation, null, timing), + TimeoutTestNodeStateProperty timeout => CreateResult(testNode.DisplayName, automatedTestName, automatedTestStorage, AzureDevOpsLivePublishingConstants.FailedTestOutcome, BuildTimeoutMessage(timeout), timeout.Exception?.StackTrace, timing), +#pragma warning disable CS0618, MTP0001 // Type or member is obsolete + CancelledTestNodeStateProperty cancelled => CreateResult(testNode.DisplayName, automatedTestName, automatedTestStorage, AzureDevOpsLivePublishingConstants.AbortedTestOutcome, cancelled.Exception?.Message ?? cancelled.Explanation, cancelled.Exception?.StackTrace, timing), +#pragma warning restore CS0618, MTP0001 // Type or member is obsolete + _ => null, + }; + } + + private async Task FlushPendingResultsAsync(bool force, CancellationToken cancellationToken) + { + if (_publishConfiguration is null || CurrentRunId is null) + { + return; + } + + if (!force && _pendingResults.IsEmpty) + { + return; + } + + await _flushSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + while (_publishConfiguration is not null && CurrentRunId is not null) + { + if (!ShouldFlushUnsafe(force)) + { + return; + } + + List batch = []; + while (batch.Count < _options.BatchSize && _pendingResults.TryDequeue(out AzureDevOpsTestCaseResult? result)) + { + batch.Add(result); + } + + if (batch.Count == 0) + { + return; + } + + try + { + if (_coordinatedRun is not null && _runIdCoordinator is not null) + { + await _runIdCoordinator.RenewLeaseAsync(_coordinatedRun, cancellationToken).ConfigureAwait(false); + } + + await _client.PublishTestResultsAsync(_publishConfiguration, CurrentRunId.Value, batch, cancellationToken).ConfigureAwait(false); + _lastFlushTime = _clock.UtcNow; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogWarning($"{AzureDevOpsResources.AzureDevOpsLivePublishingPublishResultsFailed} {ex.Message}"); + } + } + } + finally + { + _flushSemaphore.Release(); + } + } + + private bool TryCreatePublishConfiguration(out AzureDevOpsPublishConfiguration? publishConfiguration, out string? warning) + { + publishConfiguration = null; + warning = null; + + List missingVariables = []; + + bool isTfBuild = string.Equals(_environment.GetEnvironmentVariable("TF_BUILD"), "true", StringComparison.OrdinalIgnoreCase); + if (!isTfBuild) + { + missingVariables.Add("TF_BUILD=true"); + } + + string? collectionUri = GetRequiredEnvironmentVariable("SYSTEM_COLLECTIONURI", missingVariables); + string? project = GetRequiredEnvironmentVariable("SYSTEM_TEAMPROJECT", missingVariables); + string? accessToken = GetRequiredEnvironmentVariable("SYSTEM_ACCESSTOKEN", missingVariables); + string? buildIdText = GetRequiredEnvironmentVariable("BUILD_BUILDID", missingVariables); + + if (missingVariables.Count > 0) + { + warning = string.Format(CultureInfo.InvariantCulture, AzureDevOpsResources.AzureDevOpsLivePublishingMissingConfiguration, string.Join(", ", missingVariables)); + return false; + } + + if (!int.TryParse(buildIdText, NumberStyles.Integer, CultureInfo.InvariantCulture, out int buildId)) + { + warning = string.Format(CultureInfo.InvariantCulture, AzureDevOpsResources.AzureDevOpsLivePublishingMissingConfiguration, "BUILD_BUILDID"); + return false; + } + + string currentTestApplicationPath = _testApplicationModuleInfo.GetCurrentTestApplicationFullPath(); + string assemblyName = _testApplicationModuleInfo.TryGetAssemblyName() ?? Path.GetFileNameWithoutExtension(currentTestApplicationPath); + string automatedTestStorage = Path.GetFileNameWithoutExtension(currentTestApplicationPath); + string targetFrameworkMoniker = GetTargetFrameworkMoniker(); + string agentName = _environment.GetEnvironmentVariable("AGENT_NAME") ?? _environment.MachineName; + string? stageName = _environment.GetEnvironmentVariable("SYSTEM_STAGENAME"); + string? jobName = _environment.GetEnvironmentVariable("SYSTEM_JOBNAME"); + string runName = GetRunName(assemblyName, targetFrameworkMoniker, agentName, stageName, jobName); + string resultsDirectory = _configuration.GetTestResultDirectory(); + + if (_commandLineOptions.TryGetOptionArgumentList(AzureDevOpsCommandLineOptions.PublishAzureDevOpsRunNameOptionName, out string[]? arguments) && arguments is [string configuredRunName]) + { + runName = configuredRunName; + } + + publishConfiguration = new AzureDevOpsPublishConfiguration(collectionUri!, project!, accessToken!, buildId, runName, automatedTestStorage, resultsDirectory); + return true; + } + + private string? GetRequiredEnvironmentVariable(string variableName, List missingVariables) + { + string? value = _environment.GetEnvironmentVariable(variableName); + if (RoslynString.IsNullOrWhiteSpace(value)) + { + missingVariables.Add(variableName); + } + + return value; + } + + private bool ShouldFlushUnsafe(bool force) + => force + ? !_pendingResults.IsEmpty + : _pendingResults.Count >= _options.BatchSize || (!_pendingResults.IsEmpty && _clock.UtcNow - _lastFlushTime >= _options.FlushInterval); + + private static AzureDevOpsTestCaseResult CreateResult( + string displayName, + string automatedTestName, + string automatedTestStorage, + string outcome, + string? errorMessage, + string? stackTrace, + TimingProperty? timing) + => new( + automatedTestName, + automatedTestStorage, + displayName, + outcome, + timing is null ? null : (long)Math.Round(timing.GlobalTiming.Duration.TotalMilliseconds, MidpointRounding.AwayFromZero), + errorMessage, + stackTrace, + timing?.GlobalTiming.StartTime, + timing?.GlobalTiming.EndTime); + + private static string BuildTimeoutMessage(TimeoutTestNodeStateProperty timeout) + { + string? reason = timeout.Explanation ?? timeout.Exception?.Message; + return RoslynString.IsNullOrWhiteSpace(reason) + ? AzureDevOpsResources.AzureDevOpsLivePublishingTimeoutErrorMessage + : string.Format(CultureInfo.InvariantCulture, AzureDevOpsResources.AzureDevOpsLivePublishingTimeoutErrorMessageWithReason, reason); + } + + private static string GetRunName(string assemblyName, string targetFrameworkMoniker, string agentName, string? stageName, string? jobName) + { + string runName = $"{assemblyName} ({targetFrameworkMoniker}) on {agentName}"; + string? stageJob = (SanitizeRunNameComponent(stageName), SanitizeRunNameComponent(jobName)) switch + { + ({ Length: > 0 } stage, { Length: > 0 } job) => $"{stage}/{job}", + ({ Length: > 0 } stage, _) => stage, + (_, { Length: > 0 } job) => job, + _ => null, + }; + + string candidateRunName = stageJob is null ? runName : $"{runName} [{stageJob}]"; + return candidateRunName.Length <= AzureDevOpsLivePublishingConstants.MaxRunNameLength + ? candidateRunName + : candidateRunName[..AzureDevOpsLivePublishingConstants.MaxRunNameLength]; + } + + private static string? SanitizeRunNameComponent(string? value) + { + if (RoslynString.IsNullOrWhiteSpace(value)) + { + return null; + } + + char[] buffer = value.ToCharArray(); + for (int index = 0; index < buffer.Length; index++) + { + char current = buffer[index]; + if (current is '/' or '\\' or '\r' or '\n' || char.IsControl(current)) + { + buffer[index] = '_'; + } + } + + return new string(buffer); + } + + private static string GetTargetFrameworkMoniker() + => TargetFrameworkParser.GetShortTargetFramework(Assembly.GetEntryAssembly()?.GetCustomAttribute()?.FrameworkDisplayName) + ?? TargetFrameworkParser.GetShortTargetFramework(RuntimeInformation.FrameworkDescription); +} diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/IAzureDevOpsTestResultsClient.cs b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/IAzureDevOpsTestResultsClient.cs new file mode 100644 index 0000000000..e15edd0077 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/IAzureDevOpsTestResultsClient.cs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Microsoft.Testing.Extensions.AzureDevOpsReport; + +internal interface IAzureDevOpsTestResultsClient +{ + Task CreateTestRunAsync(AzureDevOpsPublishConfiguration configuration, CancellationToken cancellationToken); + + Task PublishTestResultsAsync(AzureDevOpsPublishConfiguration configuration, int runId, IReadOnlyList results, CancellationToken cancellationToken); + + Task UpdateTestRunStateAsync(AzureDevOpsPublishConfiguration configuration, int runId, string state, CancellationToken cancellationToken); +} diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Microsoft.Testing.Extensions.AzureDevOpsReport.csproj b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Microsoft.Testing.Extensions.AzureDevOpsReport.csproj index 025d2c16f3..0c74785f45 100644 --- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Microsoft.Testing.Extensions.AzureDevOpsReport.csproj +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Microsoft.Testing.Extensions.AzureDevOpsReport.csproj @@ -51,6 +51,10 @@ This package extends Microsoft Testing Platform to provide a Azure DevOps report + + + + diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/AzureDevOpsResources.resx b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/AzureDevOpsResources.resx index 516d5d4382..09098b8684 100644 --- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/AzureDevOpsResources.resx +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/AzureDevOpsResources.resx @@ -119,6 +119,7 @@ '--report-azdo-severity' requires '--report-azdo' to be enabled + {Locked='--report-azdo-severity'}{Locked='--report-azdo'} Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. @@ -130,10 +131,62 @@ Invalid option {0}. - Eanble Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. + Enable Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. Severity to use for the reported event. Options are: error (default) and warning. {Locked="error"}{Locked="warning"} + + Custom Azure DevOps test run name for live test-result publishing. + + + '--publish-azdo-run-name' requires '--publish-azdo-test-results' to be enabled + {Locked='--publish-azdo-run-name'}{Locked='--publish-azdo-test-results'} + + + Publish test results live to the Azure DevOps Tests tab. + + + Azure DevOps live publishing failed to complete the test run. + + + Azure DevOps live publishing failed to create the test run. + + + Azure DevOps live publishing failed to delete coordination file + + + Azure DevOps live publishing timed out after {0} waiting for {1} participant file(s); finalizing the run anyway. + + + Azure DevOps REST API request failed with status code {0}. Response: {1} + + + Azure DevOps REST API returned an unexpected response payload. + + + '--publish-azdo-test-results' was set, but Azure DevOps live publishing is disabled because the following environment variables are missing or invalid: {0}. + {Locked='--publish-azdo-test-results'} + + + Azure DevOps live publishing could not locate azdo-runid.<buildId>.json for the shared run. + {Locked="azdo-runid.<buildId>.json"} + + + Azure DevOps live publishing failed to publish test results. + + + Azure DevOps REST API request failed after all retries. + + + Azure DevOps live publishing found an azdo-runid.<buildId>.json file for a different Azure DevOps project. + {Locked="azdo-runid.<buildId>.json"} + + + Timeout + + + Timeout: {0} + \ No newline at end of file diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.cs.xlf b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.cs.xlf index 076a3037b9..51a1fe030c 100644 --- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.cs.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.cs.xlf @@ -2,10 +2,75 @@ + + Azure DevOps live publishing failed to complete the test run. + Azure DevOps live publishing failed to complete the test run. + + + + Azure DevOps live publishing failed to create the test run. + Azure DevOps live publishing failed to create the test run. + + + + Azure DevOps live publishing failed to delete coordination file + Azure DevOps live publishing failed to delete coordination file + + + + Azure DevOps live publishing timed out after {0} waiting for {1} participant file(s); finalizing the run anyway. + Azure DevOps live publishing timed out after {0} waiting for {1} participant file(s); finalizing the run anyway. + + + + Azure DevOps REST API request failed with status code {0}. Response: {1} + Azure DevOps REST API request failed with status code {0}. Response: {1} + + + + Azure DevOps REST API returned an unexpected response payload. + Azure DevOps REST API returned an unexpected response payload. + + + + '--publish-azdo-test-results' was set, but Azure DevOps live publishing is disabled because the following environment variables are missing or invalid: {0}. + '--publish-azdo-test-results' was set, but Azure DevOps live publishing is disabled because the following environment variables are missing or invalid: {0}. + {Locked='--publish-azdo-test-results'} + + + Azure DevOps live publishing could not locate azdo-runid.<buildId>.json for the shared run. + Azure DevOps live publishing could not locate azdo-runid.<buildId>.json for the shared run. + {Locked="azdo-runid.<buildId>.json"} + + + Azure DevOps live publishing failed to publish test results. + Azure DevOps live publishing failed to publish test results. + + + + Azure DevOps REST API request failed after all retries. + Azure DevOps REST API request failed after all retries. + + + + Azure DevOps live publishing found an azdo-runid.<buildId>.json file for a different Azure DevOps project. + Azure DevOps live publishing found an azdo-runid.<buildId>.json file for a different Azure DevOps project. + {Locked="azdo-runid.<buildId>.json"} + + + Timeout + Timeout + + + + Timeout: {0} + Timeout: {0} + + '--report-azdo-severity' requires '--report-azdo' to be enabled - --report-azdo-severity vyžaduje, aby byla povolena možnost --report-azdo - + --report-azdo-severity vyžaduje, aby byla povolena možnost --report-azdo + {Locked='--report-azdo-severity'}{Locked='--report-azdo'} Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. @@ -23,8 +88,23 @@ - Eanble Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. - Umožňuje generátoru sestav Azure DevOps zapisovat chyby do výstupu způsobem, který je pro AzureDev Ops srozumitelný. + Enable Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. + Umožňuje generátoru sestav Azure DevOps zapisovat chyby do výstupu způsobem, který je pro AzureDev Ops srozumitelný. + + + + Custom Azure DevOps test run name for live test-result publishing. + Custom Azure DevOps test run name for live test-result publishing. + + + + '--publish-azdo-run-name' requires '--publish-azdo-test-results' to be enabled + '--publish-azdo-run-name' requires '--publish-azdo-test-results' to be enabled + {Locked='--publish-azdo-run-name'}{Locked='--publish-azdo-test-results'} + + + Publish test results live to the Azure DevOps Tests tab. + Publish test results live to the Azure DevOps Tests tab. diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.de.xlf b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.de.xlf index 87be6a6ce5..eaa37b4983 100644 --- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.de.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.de.xlf @@ -2,10 +2,75 @@ + + Azure DevOps live publishing failed to complete the test run. + Azure DevOps live publishing failed to complete the test run. + + + + Azure DevOps live publishing failed to create the test run. + Azure DevOps live publishing failed to create the test run. + + + + Azure DevOps live publishing failed to delete coordination file + Azure DevOps live publishing failed to delete coordination file + + + + Azure DevOps live publishing timed out after {0} waiting for {1} participant file(s); finalizing the run anyway. + Azure DevOps live publishing timed out after {0} waiting for {1} participant file(s); finalizing the run anyway. + + + + Azure DevOps REST API request failed with status code {0}. Response: {1} + Azure DevOps REST API request failed with status code {0}. Response: {1} + + + + Azure DevOps REST API returned an unexpected response payload. + Azure DevOps REST API returned an unexpected response payload. + + + + '--publish-azdo-test-results' was set, but Azure DevOps live publishing is disabled because the following environment variables are missing or invalid: {0}. + '--publish-azdo-test-results' was set, but Azure DevOps live publishing is disabled because the following environment variables are missing or invalid: {0}. + {Locked='--publish-azdo-test-results'} + + + Azure DevOps live publishing could not locate azdo-runid.<buildId>.json for the shared run. + Azure DevOps live publishing could not locate azdo-runid.<buildId>.json for the shared run. + {Locked="azdo-runid.<buildId>.json"} + + + Azure DevOps live publishing failed to publish test results. + Azure DevOps live publishing failed to publish test results. + + + + Azure DevOps REST API request failed after all retries. + Azure DevOps REST API request failed after all retries. + + + + Azure DevOps live publishing found an azdo-runid.<buildId>.json file for a different Azure DevOps project. + Azure DevOps live publishing found an azdo-runid.<buildId>.json file for a different Azure DevOps project. + {Locked="azdo-runid.<buildId>.json"} + + + Timeout + Timeout + + + + Timeout: {0} + Timeout: {0} + + '--report-azdo-severity' requires '--report-azdo' to be enabled - Für „--report-azdo-severity“ muss „--report-azdo“ aktiviert sein. - + Für „--report-azdo-severity“ muss „--report-azdo“ aktiviert sein. + {Locked='--report-azdo-severity'}{Locked='--report-azdo'} Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. @@ -23,8 +88,23 @@ - Eanble Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. - Azure DevOps-Berichts-Generator aktivieren, um Fehler auf eine Weise in die Ausgabe zu schreiben, die AzureDev Ops versteht. + Enable Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. + Azure DevOps-Berichts-Generator aktivieren, um Fehler auf eine Weise in die Ausgabe zu schreiben, die AzureDev Ops versteht. + + + + Custom Azure DevOps test run name for live test-result publishing. + Custom Azure DevOps test run name for live test-result publishing. + + + + '--publish-azdo-run-name' requires '--publish-azdo-test-results' to be enabled + '--publish-azdo-run-name' requires '--publish-azdo-test-results' to be enabled + {Locked='--publish-azdo-run-name'}{Locked='--publish-azdo-test-results'} + + + Publish test results live to the Azure DevOps Tests tab. + Publish test results live to the Azure DevOps Tests tab. diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.es.xlf b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.es.xlf index c4a61c952f..90bf6f0606 100644 --- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.es.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.es.xlf @@ -2,10 +2,75 @@ + + Azure DevOps live publishing failed to complete the test run. + Azure DevOps live publishing failed to complete the test run. + + + + Azure DevOps live publishing failed to create the test run. + Azure DevOps live publishing failed to create the test run. + + + + Azure DevOps live publishing failed to delete coordination file + Azure DevOps live publishing failed to delete coordination file + + + + Azure DevOps live publishing timed out after {0} waiting for {1} participant file(s); finalizing the run anyway. + Azure DevOps live publishing timed out after {0} waiting for {1} participant file(s); finalizing the run anyway. + + + + Azure DevOps REST API request failed with status code {0}. Response: {1} + Azure DevOps REST API request failed with status code {0}. Response: {1} + + + + Azure DevOps REST API returned an unexpected response payload. + Azure DevOps REST API returned an unexpected response payload. + + + + '--publish-azdo-test-results' was set, but Azure DevOps live publishing is disabled because the following environment variables are missing or invalid: {0}. + '--publish-azdo-test-results' was set, but Azure DevOps live publishing is disabled because the following environment variables are missing or invalid: {0}. + {Locked='--publish-azdo-test-results'} + + + Azure DevOps live publishing could not locate azdo-runid.<buildId>.json for the shared run. + Azure DevOps live publishing could not locate azdo-runid.<buildId>.json for the shared run. + {Locked="azdo-runid.<buildId>.json"} + + + Azure DevOps live publishing failed to publish test results. + Azure DevOps live publishing failed to publish test results. + + + + Azure DevOps REST API request failed after all retries. + Azure DevOps REST API request failed after all retries. + + + + Azure DevOps live publishing found an azdo-runid.<buildId>.json file for a different Azure DevOps project. + Azure DevOps live publishing found an azdo-runid.<buildId>.json file for a different Azure DevOps project. + {Locked="azdo-runid.<buildId>.json"} + + + Timeout + Timeout + + + + Timeout: {0} + Timeout: {0} + + '--report-azdo-severity' requires '--report-azdo' to be enabled - "--report-azdo-severity" requiere que se habilite "--report-azdo" - + "--report-azdo-severity" requiere que se habilite "--report-azdo" + {Locked='--report-azdo-severity'}{Locked='--report-azdo'} Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. @@ -23,8 +88,23 @@ - Eanble Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. - Habilite el generador de informes de Azure DevOps para escribir errores en la salida de una manera que AzureDev Ops comprenda. + Enable Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. + Habilite el generador de informes de Azure DevOps para escribir errores en la salida de una manera que AzureDev Ops comprenda. + + + + Custom Azure DevOps test run name for live test-result publishing. + Custom Azure DevOps test run name for live test-result publishing. + + + + '--publish-azdo-run-name' requires '--publish-azdo-test-results' to be enabled + '--publish-azdo-run-name' requires '--publish-azdo-test-results' to be enabled + {Locked='--publish-azdo-run-name'}{Locked='--publish-azdo-test-results'} + + + Publish test results live to the Azure DevOps Tests tab. + Publish test results live to the Azure DevOps Tests tab. diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.fr.xlf b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.fr.xlf index 4feb6d0ab7..7d07ef16c2 100644 --- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.fr.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.fr.xlf @@ -2,10 +2,75 @@ + + Azure DevOps live publishing failed to complete the test run. + Azure DevOps live publishing failed to complete the test run. + + + + Azure DevOps live publishing failed to create the test run. + Azure DevOps live publishing failed to create the test run. + + + + Azure DevOps live publishing failed to delete coordination file + Azure DevOps live publishing failed to delete coordination file + + + + Azure DevOps live publishing timed out after {0} waiting for {1} participant file(s); finalizing the run anyway. + Azure DevOps live publishing timed out after {0} waiting for {1} participant file(s); finalizing the run anyway. + + + + Azure DevOps REST API request failed with status code {0}. Response: {1} + Azure DevOps REST API request failed with status code {0}. Response: {1} + + + + Azure DevOps REST API returned an unexpected response payload. + Azure DevOps REST API returned an unexpected response payload. + + + + '--publish-azdo-test-results' was set, but Azure DevOps live publishing is disabled because the following environment variables are missing or invalid: {0}. + '--publish-azdo-test-results' was set, but Azure DevOps live publishing is disabled because the following environment variables are missing or invalid: {0}. + {Locked='--publish-azdo-test-results'} + + + Azure DevOps live publishing could not locate azdo-runid.<buildId>.json for the shared run. + Azure DevOps live publishing could not locate azdo-runid.<buildId>.json for the shared run. + {Locked="azdo-runid.<buildId>.json"} + + + Azure DevOps live publishing failed to publish test results. + Azure DevOps live publishing failed to publish test results. + + + + Azure DevOps REST API request failed after all retries. + Azure DevOps REST API request failed after all retries. + + + + Azure DevOps live publishing found an azdo-runid.<buildId>.json file for a different Azure DevOps project. + Azure DevOps live publishing found an azdo-runid.<buildId>.json file for a different Azure DevOps project. + {Locked="azdo-runid.<buildId>.json"} + + + Timeout + Timeout + + + + Timeout: {0} + Timeout: {0} + + '--report-azdo-severity' requires '--report-azdo' to be enabled - « --report-azdo-severity » nécessite l’activation de « --report-azdo » - + « --report-azdo-severity » nécessite l’activation de « --report-azdo » + {Locked='--report-azdo-severity'}{Locked='--report-azdo'} Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. @@ -23,8 +88,23 @@ - Eanble Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. - Activez le générateur de rapports Azure DevOps pour écrire les erreurs dans la sortie d’une manière comprise par Azure DevOps. + Enable Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. + Activez le générateur de rapports Azure DevOps pour écrire les erreurs dans la sortie d’une manière comprise par Azure DevOps. + + + + Custom Azure DevOps test run name for live test-result publishing. + Custom Azure DevOps test run name for live test-result publishing. + + + + '--publish-azdo-run-name' requires '--publish-azdo-test-results' to be enabled + '--publish-azdo-run-name' requires '--publish-azdo-test-results' to be enabled + {Locked='--publish-azdo-run-name'}{Locked='--publish-azdo-test-results'} + + + Publish test results live to the Azure DevOps Tests tab. + Publish test results live to the Azure DevOps Tests tab. diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.it.xlf b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.it.xlf index d9756f675c..bb900974f8 100644 --- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.it.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.it.xlf @@ -2,10 +2,75 @@ + + Azure DevOps live publishing failed to complete the test run. + Azure DevOps live publishing failed to complete the test run. + + + + Azure DevOps live publishing failed to create the test run. + Azure DevOps live publishing failed to create the test run. + + + + Azure DevOps live publishing failed to delete coordination file + Azure DevOps live publishing failed to delete coordination file + + + + Azure DevOps live publishing timed out after {0} waiting for {1} participant file(s); finalizing the run anyway. + Azure DevOps live publishing timed out after {0} waiting for {1} participant file(s); finalizing the run anyway. + + + + Azure DevOps REST API request failed with status code {0}. Response: {1} + Azure DevOps REST API request failed with status code {0}. Response: {1} + + + + Azure DevOps REST API returned an unexpected response payload. + Azure DevOps REST API returned an unexpected response payload. + + + + '--publish-azdo-test-results' was set, but Azure DevOps live publishing is disabled because the following environment variables are missing or invalid: {0}. + '--publish-azdo-test-results' was set, but Azure DevOps live publishing is disabled because the following environment variables are missing or invalid: {0}. + {Locked='--publish-azdo-test-results'} + + + Azure DevOps live publishing could not locate azdo-runid.<buildId>.json for the shared run. + Azure DevOps live publishing could not locate azdo-runid.<buildId>.json for the shared run. + {Locked="azdo-runid.<buildId>.json"} + + + Azure DevOps live publishing failed to publish test results. + Azure DevOps live publishing failed to publish test results. + + + + Azure DevOps REST API request failed after all retries. + Azure DevOps REST API request failed after all retries. + + + + Azure DevOps live publishing found an azdo-runid.<buildId>.json file for a different Azure DevOps project. + Azure DevOps live publishing found an azdo-runid.<buildId>.json file for a different Azure DevOps project. + {Locked="azdo-runid.<buildId>.json"} + + + Timeout + Timeout + + + + Timeout: {0} + Timeout: {0} + + '--report-azdo-severity' requires '--report-azdo' to be enabled - '--report-azdo-severity' richiede l'autenticazione di '--report-azdo' - + '--report-azdo-severity' richiede l'autenticazione di '--report-azdo' + {Locked='--report-azdo-severity'}{Locked='--report-azdo'} Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. @@ -23,8 +88,23 @@ - Eanble Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. - Abilitare il generatore di report di Azure DevOps per scrivere gli errori nell'output in un modo che Azure DevOps comprenda. + Enable Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. + Abilitare il generatore di report di Azure DevOps per scrivere gli errori nell'output in un modo che Azure DevOps comprenda. + + + + Custom Azure DevOps test run name for live test-result publishing. + Custom Azure DevOps test run name for live test-result publishing. + + + + '--publish-azdo-run-name' requires '--publish-azdo-test-results' to be enabled + '--publish-azdo-run-name' requires '--publish-azdo-test-results' to be enabled + {Locked='--publish-azdo-run-name'}{Locked='--publish-azdo-test-results'} + + + Publish test results live to the Azure DevOps Tests tab. + Publish test results live to the Azure DevOps Tests tab. diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.ja.xlf b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.ja.xlf index ea3529945b..461b82a0f2 100644 --- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.ja.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.ja.xlf @@ -2,10 +2,75 @@ + + Azure DevOps live publishing failed to complete the test run. + Azure DevOps live publishing failed to complete the test run. + + + + Azure DevOps live publishing failed to create the test run. + Azure DevOps live publishing failed to create the test run. + + + + Azure DevOps live publishing failed to delete coordination file + Azure DevOps live publishing failed to delete coordination file + + + + Azure DevOps live publishing timed out after {0} waiting for {1} participant file(s); finalizing the run anyway. + Azure DevOps live publishing timed out after {0} waiting for {1} participant file(s); finalizing the run anyway. + + + + Azure DevOps REST API request failed with status code {0}. Response: {1} + Azure DevOps REST API request failed with status code {0}. Response: {1} + + + + Azure DevOps REST API returned an unexpected response payload. + Azure DevOps REST API returned an unexpected response payload. + + + + '--publish-azdo-test-results' was set, but Azure DevOps live publishing is disabled because the following environment variables are missing or invalid: {0}. + '--publish-azdo-test-results' was set, but Azure DevOps live publishing is disabled because the following environment variables are missing or invalid: {0}. + {Locked='--publish-azdo-test-results'} + + + Azure DevOps live publishing could not locate azdo-runid.<buildId>.json for the shared run. + Azure DevOps live publishing could not locate azdo-runid.<buildId>.json for the shared run. + {Locked="azdo-runid.<buildId>.json"} + + + Azure DevOps live publishing failed to publish test results. + Azure DevOps live publishing failed to publish test results. + + + + Azure DevOps REST API request failed after all retries. + Azure DevOps REST API request failed after all retries. + + + + Azure DevOps live publishing found an azdo-runid.<buildId>.json file for a different Azure DevOps project. + Azure DevOps live publishing found an azdo-runid.<buildId>.json file for a different Azure DevOps project. + {Locked="azdo-runid.<buildId>.json"} + + + Timeout + Timeout + + + + Timeout: {0} + Timeout: {0} + + '--report-azdo-severity' requires '--report-azdo' to be enabled - '--report-azdo-severity' では、'--report-azdo' を有効にする必要があります - + '--report-azdo-severity' では、'--report-azdo' を有効にする必要があります + {Locked='--report-azdo-severity'}{Locked='--report-azdo'} Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. @@ -23,8 +88,23 @@ - Eanble Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. - Azure DevOps レポート生成プログラムを有効にして、AzureDev Ops が理解する方法で出力にエラーを書き込みます。 + Enable Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. + Azure DevOps レポート生成プログラムを有効にして、AzureDev Ops が理解する方法で出力にエラーを書き込みます。 + + + + Custom Azure DevOps test run name for live test-result publishing. + Custom Azure DevOps test run name for live test-result publishing. + + + + '--publish-azdo-run-name' requires '--publish-azdo-test-results' to be enabled + '--publish-azdo-run-name' requires '--publish-azdo-test-results' to be enabled + {Locked='--publish-azdo-run-name'}{Locked='--publish-azdo-test-results'} + + + Publish test results live to the Azure DevOps Tests tab. + Publish test results live to the Azure DevOps Tests tab. diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.ko.xlf b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.ko.xlf index f74caa7f6e..fcaf5110d9 100644 --- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.ko.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.ko.xlf @@ -2,10 +2,75 @@ + + Azure DevOps live publishing failed to complete the test run. + Azure DevOps live publishing failed to complete the test run. + + + + Azure DevOps live publishing failed to create the test run. + Azure DevOps live publishing failed to create the test run. + + + + Azure DevOps live publishing failed to delete coordination file + Azure DevOps live publishing failed to delete coordination file + + + + Azure DevOps live publishing timed out after {0} waiting for {1} participant file(s); finalizing the run anyway. + Azure DevOps live publishing timed out after {0} waiting for {1} participant file(s); finalizing the run anyway. + + + + Azure DevOps REST API request failed with status code {0}. Response: {1} + Azure DevOps REST API request failed with status code {0}. Response: {1} + + + + Azure DevOps REST API returned an unexpected response payload. + Azure DevOps REST API returned an unexpected response payload. + + + + '--publish-azdo-test-results' was set, but Azure DevOps live publishing is disabled because the following environment variables are missing or invalid: {0}. + '--publish-azdo-test-results' was set, but Azure DevOps live publishing is disabled because the following environment variables are missing or invalid: {0}. + {Locked='--publish-azdo-test-results'} + + + Azure DevOps live publishing could not locate azdo-runid.<buildId>.json for the shared run. + Azure DevOps live publishing could not locate azdo-runid.<buildId>.json for the shared run. + {Locked="azdo-runid.<buildId>.json"} + + + Azure DevOps live publishing failed to publish test results. + Azure DevOps live publishing failed to publish test results. + + + + Azure DevOps REST API request failed after all retries. + Azure DevOps REST API request failed after all retries. + + + + Azure DevOps live publishing found an azdo-runid.<buildId>.json file for a different Azure DevOps project. + Azure DevOps live publishing found an azdo-runid.<buildId>.json file for a different Azure DevOps project. + {Locked="azdo-runid.<buildId>.json"} + + + Timeout + Timeout + + + + Timeout: {0} + Timeout: {0} + + '--report-azdo-severity' requires '--report-azdo' to be enabled - '--report-azdo-severity'를 사용하려면 '--report-azdo'를 사용 설정 해야합니다. - + '--report-azdo-severity'를 사용하려면 '--report-azdo'를 사용 설정 해야합니다. + {Locked='--report-azdo-severity'}{Locked='--report-azdo'} Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. @@ -23,8 +88,23 @@ - Eanble Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. - Azure DevOps 보고서 생성기를 활성화하여 Azure DevOps가 이해할 수 있는 방식으로 출력에 오류를 기록합니다. + Enable Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. + Azure DevOps 보고서 생성기를 활성화하여 Azure DevOps가 이해할 수 있는 방식으로 출력에 오류를 기록합니다. + + + + Custom Azure DevOps test run name for live test-result publishing. + Custom Azure DevOps test run name for live test-result publishing. + + + + '--publish-azdo-run-name' requires '--publish-azdo-test-results' to be enabled + '--publish-azdo-run-name' requires '--publish-azdo-test-results' to be enabled + {Locked='--publish-azdo-run-name'}{Locked='--publish-azdo-test-results'} + + + Publish test results live to the Azure DevOps Tests tab. + Publish test results live to the Azure DevOps Tests tab. diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.pl.xlf b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.pl.xlf index 8890f27f70..4f879f783c 100644 --- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.pl.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.pl.xlf @@ -2,10 +2,75 @@ + + Azure DevOps live publishing failed to complete the test run. + Azure DevOps live publishing failed to complete the test run. + + + + Azure DevOps live publishing failed to create the test run. + Azure DevOps live publishing failed to create the test run. + + + + Azure DevOps live publishing failed to delete coordination file + Azure DevOps live publishing failed to delete coordination file + + + + Azure DevOps live publishing timed out after {0} waiting for {1} participant file(s); finalizing the run anyway. + Azure DevOps live publishing timed out after {0} waiting for {1} participant file(s); finalizing the run anyway. + + + + Azure DevOps REST API request failed with status code {0}. Response: {1} + Azure DevOps REST API request failed with status code {0}. Response: {1} + + + + Azure DevOps REST API returned an unexpected response payload. + Azure DevOps REST API returned an unexpected response payload. + + + + '--publish-azdo-test-results' was set, but Azure DevOps live publishing is disabled because the following environment variables are missing or invalid: {0}. + '--publish-azdo-test-results' was set, but Azure DevOps live publishing is disabled because the following environment variables are missing or invalid: {0}. + {Locked='--publish-azdo-test-results'} + + + Azure DevOps live publishing could not locate azdo-runid.<buildId>.json for the shared run. + Azure DevOps live publishing could not locate azdo-runid.<buildId>.json for the shared run. + {Locked="azdo-runid.<buildId>.json"} + + + Azure DevOps live publishing failed to publish test results. + Azure DevOps live publishing failed to publish test results. + + + + Azure DevOps REST API request failed after all retries. + Azure DevOps REST API request failed after all retries. + + + + Azure DevOps live publishing found an azdo-runid.<buildId>.json file for a different Azure DevOps project. + Azure DevOps live publishing found an azdo-runid.<buildId>.json file for a different Azure DevOps project. + {Locked="azdo-runid.<buildId>.json"} + + + Timeout + Timeout + + + + Timeout: {0} + Timeout: {0} + + '--report-azdo-severity' requires '--report-azdo' to be enabled - Element „--report-azdo-severity” wymaga włączenia polecenia „--report-azdo” - + Element „--report-azdo-severity” wymaga włączenia polecenia „--report-azdo” + {Locked='--report-azdo-severity'}{Locked='--report-azdo'} Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. @@ -23,8 +88,23 @@ - Eanble Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. - Włącz generator raportów usługi Azure DevOps, aby zapisywać błędy w danych wyjściowych w sposób zrozumiały dla usługi Azure DevOps. + Enable Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. + Włącz generator raportów usługi Azure DevOps, aby zapisywać błędy w danych wyjściowych w sposób zrozumiały dla usługi Azure DevOps. + + + + Custom Azure DevOps test run name for live test-result publishing. + Custom Azure DevOps test run name for live test-result publishing. + + + + '--publish-azdo-run-name' requires '--publish-azdo-test-results' to be enabled + '--publish-azdo-run-name' requires '--publish-azdo-test-results' to be enabled + {Locked='--publish-azdo-run-name'}{Locked='--publish-azdo-test-results'} + + + Publish test results live to the Azure DevOps Tests tab. + Publish test results live to the Azure DevOps Tests tab. diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.pt-BR.xlf b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.pt-BR.xlf index d660d0e56a..21302a6994 100644 --- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.pt-BR.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.pt-BR.xlf @@ -2,10 +2,75 @@ + + Azure DevOps live publishing failed to complete the test run. + Azure DevOps live publishing failed to complete the test run. + + + + Azure DevOps live publishing failed to create the test run. + Azure DevOps live publishing failed to create the test run. + + + + Azure DevOps live publishing failed to delete coordination file + Azure DevOps live publishing failed to delete coordination file + + + + Azure DevOps live publishing timed out after {0} waiting for {1} participant file(s); finalizing the run anyway. + Azure DevOps live publishing timed out after {0} waiting for {1} participant file(s); finalizing the run anyway. + + + + Azure DevOps REST API request failed with status code {0}. Response: {1} + Azure DevOps REST API request failed with status code {0}. Response: {1} + + + + Azure DevOps REST API returned an unexpected response payload. + Azure DevOps REST API returned an unexpected response payload. + + + + '--publish-azdo-test-results' was set, but Azure DevOps live publishing is disabled because the following environment variables are missing or invalid: {0}. + '--publish-azdo-test-results' was set, but Azure DevOps live publishing is disabled because the following environment variables are missing or invalid: {0}. + {Locked='--publish-azdo-test-results'} + + + Azure DevOps live publishing could not locate azdo-runid.<buildId>.json for the shared run. + Azure DevOps live publishing could not locate azdo-runid.<buildId>.json for the shared run. + {Locked="azdo-runid.<buildId>.json"} + + + Azure DevOps live publishing failed to publish test results. + Azure DevOps live publishing failed to publish test results. + + + + Azure DevOps REST API request failed after all retries. + Azure DevOps REST API request failed after all retries. + + + + Azure DevOps live publishing found an azdo-runid.<buildId>.json file for a different Azure DevOps project. + Azure DevOps live publishing found an azdo-runid.<buildId>.json file for a different Azure DevOps project. + {Locked="azdo-runid.<buildId>.json"} + + + Timeout + Timeout + + + + Timeout: {0} + Timeout: {0} + + '--report-azdo-severity' requires '--report-azdo' to be enabled - '--report-azdo-severity' requer que '--report-azdo' esteja habilitado - + '--report-azdo-severity' requer que '--report-azdo' esteja habilitado + {Locked='--report-azdo-severity'}{Locked='--report-azdo'} Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. @@ -23,8 +88,23 @@ - Eanble Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. - Habilitar o gerador de relatórios do Azure DevOps para gravar erros na saída de uma forma que o Azure DevOps entenda. + Enable Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. + Habilitar o gerador de relatórios do Azure DevOps para gravar erros na saída de uma forma que o Azure DevOps entenda. + + + + Custom Azure DevOps test run name for live test-result publishing. + Custom Azure DevOps test run name for live test-result publishing. + + + + '--publish-azdo-run-name' requires '--publish-azdo-test-results' to be enabled + '--publish-azdo-run-name' requires '--publish-azdo-test-results' to be enabled + {Locked='--publish-azdo-run-name'}{Locked='--publish-azdo-test-results'} + + + Publish test results live to the Azure DevOps Tests tab. + Publish test results live to the Azure DevOps Tests tab. diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.ru.xlf b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.ru.xlf index 756d7c9cae..45dc2fac2c 100644 --- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.ru.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.ru.xlf @@ -2,10 +2,75 @@ + + Azure DevOps live publishing failed to complete the test run. + Azure DevOps live publishing failed to complete the test run. + + + + Azure DevOps live publishing failed to create the test run. + Azure DevOps live publishing failed to create the test run. + + + + Azure DevOps live publishing failed to delete coordination file + Azure DevOps live publishing failed to delete coordination file + + + + Azure DevOps live publishing timed out after {0} waiting for {1} participant file(s); finalizing the run anyway. + Azure DevOps live publishing timed out after {0} waiting for {1} participant file(s); finalizing the run anyway. + + + + Azure DevOps REST API request failed with status code {0}. Response: {1} + Azure DevOps REST API request failed with status code {0}. Response: {1} + + + + Azure DevOps REST API returned an unexpected response payload. + Azure DevOps REST API returned an unexpected response payload. + + + + '--publish-azdo-test-results' was set, but Azure DevOps live publishing is disabled because the following environment variables are missing or invalid: {0}. + '--publish-azdo-test-results' was set, but Azure DevOps live publishing is disabled because the following environment variables are missing or invalid: {0}. + {Locked='--publish-azdo-test-results'} + + + Azure DevOps live publishing could not locate azdo-runid.<buildId>.json for the shared run. + Azure DevOps live publishing could not locate azdo-runid.<buildId>.json for the shared run. + {Locked="azdo-runid.<buildId>.json"} + + + Azure DevOps live publishing failed to publish test results. + Azure DevOps live publishing failed to publish test results. + + + + Azure DevOps REST API request failed after all retries. + Azure DevOps REST API request failed after all retries. + + + + Azure DevOps live publishing found an azdo-runid.<buildId>.json file for a different Azure DevOps project. + Azure DevOps live publishing found an azdo-runid.<buildId>.json file for a different Azure DevOps project. + {Locked="azdo-runid.<buildId>.json"} + + + Timeout + Timeout + + + + Timeout: {0} + Timeout: {0} + + '--report-azdo-severity' requires '--report-azdo' to be enabled - "--report-azdo-severity" требует включения "--report-azdo" - + "--report-azdo-severity" требует включения "--report-azdo" + {Locked='--report-azdo-severity'}{Locked='--report-azdo'} Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. @@ -23,8 +88,23 @@ - Eanble Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. - Включите генератор отчетов Azure DevOps для записи ошибок в выходные данные в формате, который понимает Azure DevOps. + Enable Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. + Включите генератор отчетов Azure DevOps для записи ошибок в выходные данные в формате, который понимает Azure DevOps. + + + + Custom Azure DevOps test run name for live test-result publishing. + Custom Azure DevOps test run name for live test-result publishing. + + + + '--publish-azdo-run-name' requires '--publish-azdo-test-results' to be enabled + '--publish-azdo-run-name' requires '--publish-azdo-test-results' to be enabled + {Locked='--publish-azdo-run-name'}{Locked='--publish-azdo-test-results'} + + + Publish test results live to the Azure DevOps Tests tab. + Publish test results live to the Azure DevOps Tests tab. diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.tr.xlf b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.tr.xlf index 69207860a2..b47bb4894f 100644 --- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.tr.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.tr.xlf @@ -2,10 +2,75 @@ + + Azure DevOps live publishing failed to complete the test run. + Azure DevOps live publishing failed to complete the test run. + + + + Azure DevOps live publishing failed to create the test run. + Azure DevOps live publishing failed to create the test run. + + + + Azure DevOps live publishing failed to delete coordination file + Azure DevOps live publishing failed to delete coordination file + + + + Azure DevOps live publishing timed out after {0} waiting for {1} participant file(s); finalizing the run anyway. + Azure DevOps live publishing timed out after {0} waiting for {1} participant file(s); finalizing the run anyway. + + + + Azure DevOps REST API request failed with status code {0}. Response: {1} + Azure DevOps REST API request failed with status code {0}. Response: {1} + + + + Azure DevOps REST API returned an unexpected response payload. + Azure DevOps REST API returned an unexpected response payload. + + + + '--publish-azdo-test-results' was set, but Azure DevOps live publishing is disabled because the following environment variables are missing or invalid: {0}. + '--publish-azdo-test-results' was set, but Azure DevOps live publishing is disabled because the following environment variables are missing or invalid: {0}. + {Locked='--publish-azdo-test-results'} + + + Azure DevOps live publishing could not locate azdo-runid.<buildId>.json for the shared run. + Azure DevOps live publishing could not locate azdo-runid.<buildId>.json for the shared run. + {Locked="azdo-runid.<buildId>.json"} + + + Azure DevOps live publishing failed to publish test results. + Azure DevOps live publishing failed to publish test results. + + + + Azure DevOps REST API request failed after all retries. + Azure DevOps REST API request failed after all retries. + + + + Azure DevOps live publishing found an azdo-runid.<buildId>.json file for a different Azure DevOps project. + Azure DevOps live publishing found an azdo-runid.<buildId>.json file for a different Azure DevOps project. + {Locked="azdo-runid.<buildId>.json"} + + + Timeout + Timeout + + + + Timeout: {0} + Timeout: {0} + + '--report-azdo-severity' requires '--report-azdo' to be enabled - '--report-azdo-severity' öğesi '--report-azdo' öğesinin etkinleştirilmesini gerektirir - + '--report-azdo-severity' öğesi '--report-azdo' öğesinin etkinleştirilmesini gerektirir + {Locked='--report-azdo-severity'}{Locked='--report-azdo'} Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. @@ -23,8 +88,23 @@ - Eanble Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. - Azure DevOps rapor oluşturucusunu, hataları Azure DevOps'un anlayacağı şekilde çıktıya yazması için etkinleştirin. + Enable Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. + Azure DevOps rapor oluşturucusunu, hataları Azure DevOps'un anlayacağı şekilde çıktıya yazması için etkinleştirin. + + + + Custom Azure DevOps test run name for live test-result publishing. + Custom Azure DevOps test run name for live test-result publishing. + + + + '--publish-azdo-run-name' requires '--publish-azdo-test-results' to be enabled + '--publish-azdo-run-name' requires '--publish-azdo-test-results' to be enabled + {Locked='--publish-azdo-run-name'}{Locked='--publish-azdo-test-results'} + + + Publish test results live to the Azure DevOps Tests tab. + Publish test results live to the Azure DevOps Tests tab. diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.zh-Hans.xlf b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.zh-Hans.xlf index 2b5199f4ad..4e0f8260ac 100644 --- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.zh-Hans.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.zh-Hans.xlf @@ -2,10 +2,75 @@ + + Azure DevOps live publishing failed to complete the test run. + Azure DevOps live publishing failed to complete the test run. + + + + Azure DevOps live publishing failed to create the test run. + Azure DevOps live publishing failed to create the test run. + + + + Azure DevOps live publishing failed to delete coordination file + Azure DevOps live publishing failed to delete coordination file + + + + Azure DevOps live publishing timed out after {0} waiting for {1} participant file(s); finalizing the run anyway. + Azure DevOps live publishing timed out after {0} waiting for {1} participant file(s); finalizing the run anyway. + + + + Azure DevOps REST API request failed with status code {0}. Response: {1} + Azure DevOps REST API request failed with status code {0}. Response: {1} + + + + Azure DevOps REST API returned an unexpected response payload. + Azure DevOps REST API returned an unexpected response payload. + + + + '--publish-azdo-test-results' was set, but Azure DevOps live publishing is disabled because the following environment variables are missing or invalid: {0}. + '--publish-azdo-test-results' was set, but Azure DevOps live publishing is disabled because the following environment variables are missing or invalid: {0}. + {Locked='--publish-azdo-test-results'} + + + Azure DevOps live publishing could not locate azdo-runid.<buildId>.json for the shared run. + Azure DevOps live publishing could not locate azdo-runid.<buildId>.json for the shared run. + {Locked="azdo-runid.<buildId>.json"} + + + Azure DevOps live publishing failed to publish test results. + Azure DevOps live publishing failed to publish test results. + + + + Azure DevOps REST API request failed after all retries. + Azure DevOps REST API request failed after all retries. + + + + Azure DevOps live publishing found an azdo-runid.<buildId>.json file for a different Azure DevOps project. + Azure DevOps live publishing found an azdo-runid.<buildId>.json file for a different Azure DevOps project. + {Locked="azdo-runid.<buildId>.json"} + + + Timeout + Timeout + + + + Timeout: {0} + Timeout: {0} + + '--report-azdo-severity' requires '--report-azdo' to be enabled - '--report-azdo-severity' 要求启用 '--report-azdo' - + '--report-azdo-severity' 要求启用 '--report-azdo' + {Locked='--report-azdo-severity'}{Locked='--report-azdo'} Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. @@ -23,8 +88,23 @@ - Eanble Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. - 启用 Azure DevOps 报表生成器,以通过 Azure DevOps 可理解的方式将错误写入输出。 + Enable Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. + 启用 Azure DevOps 报表生成器,以通过 Azure DevOps 可理解的方式将错误写入输出。 + + + + Custom Azure DevOps test run name for live test-result publishing. + Custom Azure DevOps test run name for live test-result publishing. + + + + '--publish-azdo-run-name' requires '--publish-azdo-test-results' to be enabled + '--publish-azdo-run-name' requires '--publish-azdo-test-results' to be enabled + {Locked='--publish-azdo-run-name'}{Locked='--publish-azdo-test-results'} + + + Publish test results live to the Azure DevOps Tests tab. + Publish test results live to the Azure DevOps Tests tab. diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.zh-Hant.xlf b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.zh-Hant.xlf index 6c61f0fb8f..c179e68e2c 100644 --- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.zh-Hant.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.zh-Hant.xlf @@ -2,10 +2,75 @@ + + Azure DevOps live publishing failed to complete the test run. + Azure DevOps live publishing failed to complete the test run. + + + + Azure DevOps live publishing failed to create the test run. + Azure DevOps live publishing failed to create the test run. + + + + Azure DevOps live publishing failed to delete coordination file + Azure DevOps live publishing failed to delete coordination file + + + + Azure DevOps live publishing timed out after {0} waiting for {1} participant file(s); finalizing the run anyway. + Azure DevOps live publishing timed out after {0} waiting for {1} participant file(s); finalizing the run anyway. + + + + Azure DevOps REST API request failed with status code {0}. Response: {1} + Azure DevOps REST API request failed with status code {0}. Response: {1} + + + + Azure DevOps REST API returned an unexpected response payload. + Azure DevOps REST API returned an unexpected response payload. + + + + '--publish-azdo-test-results' was set, but Azure DevOps live publishing is disabled because the following environment variables are missing or invalid: {0}. + '--publish-azdo-test-results' was set, but Azure DevOps live publishing is disabled because the following environment variables are missing or invalid: {0}. + {Locked='--publish-azdo-test-results'} + + + Azure DevOps live publishing could not locate azdo-runid.<buildId>.json for the shared run. + Azure DevOps live publishing could not locate azdo-runid.<buildId>.json for the shared run. + {Locked="azdo-runid.<buildId>.json"} + + + Azure DevOps live publishing failed to publish test results. + Azure DevOps live publishing failed to publish test results. + + + + Azure DevOps REST API request failed after all retries. + Azure DevOps REST API request failed after all retries. + + + + Azure DevOps live publishing found an azdo-runid.<buildId>.json file for a different Azure DevOps project. + Azure DevOps live publishing found an azdo-runid.<buildId>.json file for a different Azure DevOps project. + {Locked="azdo-runid.<buildId>.json"} + + + Timeout + Timeout + + + + Timeout: {0} + Timeout: {0} + + '--report-azdo-severity' requires '--report-azdo' to be enabled - '--report-azdo-severity' 需要啟用 '--report-azdo' - + '--report-azdo-severity' 需要啟用 '--report-azdo' + {Locked='--report-azdo-severity'}{Locked='--report-azdo'} Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. @@ -23,8 +88,23 @@ - Eanble Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. - 啟用 Azure DevOps 報告產生器,以 Azure DevOps 可理解的方式將錯誤寫入輸出。 + Enable Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. + 啟用 Azure DevOps 報告產生器,以 Azure DevOps 可理解的方式將錯誤寫入輸出。 + + + + Custom Azure DevOps test run name for live test-result publishing. + Custom Azure DevOps test run name for live test-result publishing. + + + + '--publish-azdo-run-name' requires '--publish-azdo-test-results' to be enabled + '--publish-azdo-run-name' requires '--publish-azdo-test-results' to be enabled + {Locked='--publish-azdo-run-name'}{Locked='--publish-azdo-test-results'} + + + Publish test results live to the Azure DevOps Tests tab. + Publish test results live to the Azure DevOps Tests tab. diff --git a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/HelpInfoAllExtensionsTests.cs b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/HelpInfoAllExtensionsTests.cs index 7449a51387..4b370de5a4 100644 --- a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/HelpInfoAllExtensionsTests.cs +++ b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/HelpInfoAllExtensionsTests.cs @@ -100,6 +100,14 @@ Disable reporting progress to screen. --output Output verbosity when reporting tests. Valid values are 'Normal', 'Detailed'. Default is 'Normal'. + --publish-azdo-run-name + Custom Azure DevOps test run name for live test-result publishing. + --publish-azdo-test-results + Publish test results live to the Azure DevOps Tests tab. + --report-azdo + Enable Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. + --report-azdo-severity + Severity to use for the reported event. Options are: error (default) and warning. --report-trx Enable generating TRX report --report-trx-filename @@ -346,6 +354,27 @@ Default type is 'Full' Arity: 1 Hidden: False Description: Disable retry mechanism if the number of failed tests is greater than the specified value + AzureDevOpsCommandLineProvider + Name: Azure DevOps report generator + Version: * + Description: Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. + Options: + --publish-azdo-run-name + Arity: 1 + Hidden: False + Description: Custom Azure DevOps test run name for live test-result publishing. + --publish-azdo-test-results + Arity: 0 + Hidden: False + Description: Publish test results live to the Azure DevOps Tests tab. + --report-azdo + Arity: 0 + Hidden: False + Description: Enable Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. + --report-azdo-severity + Arity: 1 + Hidden: False + Description: Severity to use for the reported event. Options are: error (default) and warning. TerminalTestReporterCommandLineOptionsProvider Name: Terminal test reporter Version: * diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/AzureDevOpsLivePublishingTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/AzureDevOpsLivePublishingTests.cs new file mode 100644 index 0000000000..a22eababe8 --- /dev/null +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/AzureDevOpsLivePublishingTests.cs @@ -0,0 +1,577 @@ +// 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.Diagnostics; +using System.Net; +using System.Net.Http; +using System.Text.Json; + +using Microsoft.Testing.Extensions.AzureDevOpsReport; +using Microsoft.Testing.Extensions.AzureDevOpsReport.Resources; +using Microsoft.Testing.Extensions.Reporting; +using Microsoft.Testing.Platform.CommandLine; +using Microsoft.Testing.Platform.Configurations; +using Microsoft.Testing.Platform.Extensions; +using Microsoft.Testing.Platform.Extensions.Messages; +using Microsoft.Testing.Platform.Helpers; +using Microsoft.Testing.Platform.Logging; +using Microsoft.Testing.Platform.Services; +using Microsoft.Testing.Platform.TestHost; + +using Moq; + +namespace Microsoft.Testing.Extensions.UnitTests; + +[TestClass] +public sealed class AzureDevOpsLivePublishingTests +{ + private readonly List _directoriesToDelete = []; + + [TestCleanup] + public void Cleanup() + { + foreach (string path in _directoriesToDelete) + { + TryDeleteDirectory(path); + } + } + + [TestMethod] + public async Task OnTestSessionStartingAsync_CreatesRunAndStoresRunId() + { + using TestDirectory directory = CreateTestDirectory(); + AzureDevOpsTestResultsPublisher publisher = CreatePublisher(directory.Path, options: new(2, TimeSpan.FromMinutes(1), 4, TimeSpan.FromMilliseconds(1)), out FakeAzureDevOpsTestResultsClient client, out _, out _); + client.CreateTestRunAsyncFunc = (_, _) => Task.FromResult(42); + + await StartPublisherAsync(publisher); + + Assert.AreEqual(42, publisher.RunId); + } + + [TestMethod] + public async Task OnTestSessionStartingAsync_JsonExceptionLogsWarningAndDoesNotThrow() + { + using TestDirectory directory = CreateTestDirectory(); + AzureDevOpsTestResultsPublisher publisher = CreatePublisher(directory.Path, options: new(2, TimeSpan.FromMinutes(1), 4, TimeSpan.FromMilliseconds(1)), out FakeAzureDevOpsTestResultsClient client, out _, out CollectingLogger logger); + client.CreateTestRunAsyncFunc = (_, _) => Task.FromException(new JsonException("broken payload")); + + Assert.IsTrue(await publisher.IsEnabledAsync()); + await publisher.OnTestSessionStartingAsync(new Microsoft.Testing.Platform.Services.TestSessionContext(CancellationToken.None)); + + Assert.IsNull(publisher.RunId); + Assert.Contains(AzureDevOpsResources.AzureDevOpsLivePublishingCreateRunFailed, string.Join(Environment.NewLine, logger.Logs)); + } + + [TestMethod] + public async Task ConsumeAsync_FlushesWhenBatchSizeIsReached() + { + using TestDirectory directory = CreateTestDirectory(); + AzureDevOpsTestResultsPublisher publisher = CreatePublisher(directory.Path, options: new(2, TimeSpan.FromMinutes(1), 4, TimeSpan.FromMilliseconds(1)), out FakeAzureDevOpsTestResultsClient client, out FakeClock clock, out _); + client.CreateTestRunAsyncFunc = (_, _) => Task.FromResult(100); + + List> publishedBatches = []; + client.PublishTestResultsAsyncFunc = (_, _, results, _) => + { + publishedBatches.Add(results.ToArray()); + return Task.CompletedTask; + }; + + await StartPublisherAsync(publisher); + await publisher.ConsumeAsync(Mock.Of(), CreateMessage(CreateNode("test-1", new PassedTestNodeStateProperty(), clock.UtcNow)), CancellationToken.None); + await publisher.ConsumeAsync(Mock.Of(), CreateMessage(CreateNode("test-2", new PassedTestNodeStateProperty(), clock.UtcNow)), CancellationToken.None); + + Assert.HasCount(1, publishedBatches); + Assert.HasCount(2, publishedBatches[0]); + } + + [TestMethod] + public async Task ConsumeAsync_FlushesWhenFlushIntervalElapsed() + { + using TestDirectory directory = CreateTestDirectory(); + AzureDevOpsTestResultsPublisher publisher = CreatePublisher(directory.Path, options: new(10, TimeSpan.FromSeconds(5), 4, TimeSpan.FromMilliseconds(1)), out FakeAzureDevOpsTestResultsClient client, out FakeClock clock, out _); + client.CreateTestRunAsyncFunc = (_, _) => Task.FromResult(101); + + List> publishedBatches = []; + client.PublishTestResultsAsyncFunc = (_, _, results, _) => + { + publishedBatches.Add(results.ToArray()); + return Task.CompletedTask; + }; + + await StartPublisherAsync(publisher); + await publisher.ConsumeAsync(Mock.Of(), CreateMessage(CreateNode("test-1", new PassedTestNodeStateProperty(), clock.UtcNow)), CancellationToken.None); + + clock.UtcNow += TimeSpan.FromSeconds(6); + await publisher.ConsumeAsync(Mock.Of(), CreateMessage(CreateNode("test-2", new PassedTestNodeStateProperty(), clock.UtcNow)), CancellationToken.None); + + Assert.HasCount(1, publishedBatches); + Assert.HasCount(2, publishedBatches[0]); + } + + [TestMethod] + public async Task OnTestSessionFinishingAsync_FlushesRemainingResults() + { + using TestDirectory directory = CreateTestDirectory(); + AzureDevOpsTestResultsPublisher publisher = CreatePublisher(directory.Path, options: new(10, TimeSpan.FromMinutes(1), 4, TimeSpan.FromMilliseconds(1)), out FakeAzureDevOpsTestResultsClient client, out FakeClock clock, out _); + client.CreateTestRunAsyncFunc = (_, _) => Task.FromResult(102); + + List> publishedBatches = []; + client.PublishTestResultsAsyncFunc = (_, _, results, _) => + { + publishedBatches.Add(results.ToArray()); + return Task.CompletedTask; + }; + + await StartPublisherAsync(publisher); + await publisher.ConsumeAsync(Mock.Of(), CreateMessage(CreateNode("test-1", new PassedTestNodeStateProperty(), clock.UtcNow)), CancellationToken.None); + await publisher.OnTestSessionFinishingAsync(new Microsoft.Testing.Platform.Services.TestSessionContext(CancellationToken.None)); + + Assert.HasCount(1, publishedBatches); + Assert.HasCount(1, publishedBatches[0]); + Assert.HasCount(1, client.UpdateTestRunStateCalls); + Assert.AreEqual(AzureDevOpsLivePublishingConstants.CompletedTestRunState, client.UpdateTestRunStateCalls[0].State); + } + + [TestMethod] + public void CreateTestCaseResult_MapsMtpStatesToAzdoResults() + { + DateTimeOffset startTime = new(2025, 1, 1, 12, 0, 0, TimeSpan.Zero); + TimingProperty timing = new(new TimingInfo(startTime, startTime.AddSeconds(2), TimeSpan.FromSeconds(2))); + AzureDevOpsTestCaseResult? passed = AzureDevOpsTestResultsPublisher.CreateTestCaseResult(CreateNode("passed", new PassedTestNodeStateProperty(), startTime, timing), "tests.dll"); + AzureDevOpsTestCaseResult? failed = AzureDevOpsTestResultsPublisher.CreateTestCaseResult(CreateNode("failed", new FailedTestNodeStateProperty(new InvalidOperationException("boom")), startTime, timing), "tests.dll"); + AzureDevOpsTestCaseResult? skipped = AzureDevOpsTestResultsPublisher.CreateTestCaseResult(CreateNode("skipped", new SkippedTestNodeStateProperty("skip"), startTime, timing), "tests.dll"); + AzureDevOpsTestCaseResult? timeout = AzureDevOpsTestResultsPublisher.CreateTestCaseResult(CreateNode("timeout", new TimeoutTestNodeStateProperty("too slow"), startTime, timing), "tests.dll"); +#pragma warning disable CS0618, MTP0001 // Type or member is obsolete + AzureDevOpsTestCaseResult? cancelled = AzureDevOpsTestResultsPublisher.CreateTestCaseResult(CreateNode("cancelled", new CancelledTestNodeStateProperty("stopped"), startTime, timing), "tests.dll"); +#pragma warning restore CS0618, MTP0001 // Type or member is obsolete + + Assert.AreEqual(AzureDevOpsLivePublishingConstants.PassedTestOutcome, passed?.Outcome); + Assert.AreEqual(2000L, passed?.DurationInMs); + Assert.AreEqual(startTime, passed?.StartedDate); + Assert.AreEqual(AzureDevOpsLivePublishingConstants.FailedTestOutcome, failed?.Outcome); + Assert.AreEqual("boom", failed?.ErrorMessage); + Assert.AreEqual(AzureDevOpsLivePublishingConstants.NotExecutedTestOutcome, skipped?.Outcome); + Assert.AreEqual("skip", skipped?.ErrorMessage); + Assert.AreEqual(AzureDevOpsLivePublishingConstants.FailedTestOutcome, timeout?.Outcome); + Assert.AreEqual("Timeout: too slow", timeout?.ErrorMessage); + Assert.AreEqual(AzureDevOpsLivePublishingConstants.AbortedTestOutcome, cancelled?.Outcome); + Assert.AreEqual("stopped", cancelled?.ErrorMessage); + } + + [TestMethod] + public async Task CreatePublisher_UsesSanitizedRunNameAndStorageWithoutExtension() + { + using TestDirectory directory = CreateTestDirectory(); + Mock environment = CreateEnvironmentMock(processId: GetAliveProcessId(), stageName: new string('s', 240) + "/stage", jobName: "job\r\nline\u0001"); + AzureDevOpsPublishConfiguration? capturedConfiguration = null; + AzureDevOpsTestResultsPublisher publisher = CreatePublisher(directory.Path, options: new(2, TimeSpan.FromMinutes(1), 4, TimeSpan.FromMilliseconds(1)), out FakeAzureDevOpsTestResultsClient client, out _, out _, environment: environment); + client.CreateTestRunAsyncFunc = (configuration, _) => + { + capturedConfiguration = configuration; + return Task.FromResult(55); + }; + + await StartPublisherAsync(publisher); + + Assert.IsNotNull(capturedConfiguration); + AzureDevOpsPublishConfiguration configuration = capturedConfiguration!; + Assert.AreEqual("MyTests", configuration.AutomatedTestStorage); + Assert.DoesNotContain("/", configuration.RunName); + Assert.DoesNotContain("\r", configuration.RunName); + Assert.DoesNotContain("\n", configuration.RunName); + Assert.IsLessThanOrEqualTo(AzureDevOpsLivePublishingConstants.MaxRunNameLength, configuration.RunName.Length); + } + + [TestMethod] + public async Task AzureDevOpsTestResultsClient_HonorsRetryAfterOn429() + { + var events = new List(); + FakeTask task = new(delayCallback: timeSpan => events.Add($"delay:{timeSpan.TotalSeconds}")); + FakeClock clock = new() { UtcNow = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero) }; + QueueHttpMessageHandler handler = new( + (request, cancellationToken) => + { + events.Add("send:1"); + HttpResponseMessage response = new((HttpStatusCode)429) + { + Content = new StringContent("{}"), + }; + response.Headers.RetryAfter = new System.Net.Http.Headers.RetryConditionHeaderValue(TimeSpan.FromSeconds(3)); + return Task.FromResult(response); + }, + (request, cancellationToken) => + { + events.Add("send:2"); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{\"id\":7}"), + }); + }); + using HttpClient httpClient = new(handler) + { + Timeout = Timeout.InfiniteTimeSpan, + }; + AzureDevOpsTestResultsClient client = new(httpClient, task, clock); + + int runId = await client.CreateTestRunAsync(new AzureDevOpsPublishConfiguration("https://dev.azure.com/org/", "project", "token", 1, "run", "tests.dll", "results"), CancellationToken.None); + + Assert.AreEqual(7, runId); + Assert.HasCount(1, task.DelayCalls); + Assert.AreEqual(TimeSpan.FromSeconds(3), task.DelayCalls[0]); + CollectionAssert.AreEqual(new[] { "send:1", "delay:3", "send:2" }, events); + } + + [TestMethod] + public async Task AzureDevOpsTestResultsClient_RetriesTaskCanceledException() + { + FakeTask task = new(); + FakeClock clock = new() { UtcNow = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero) }; + QueueHttpMessageHandler handler = new( + (request, cancellationToken) => Task.FromException(new TaskCanceledException("timeout")), + (request, cancellationToken) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{\"id\":8}"), + })); + using HttpClient httpClient = new(handler) + { + Timeout = Timeout.InfiniteTimeSpan, + }; + AzureDevOpsTestResultsClient client = new(httpClient, task, clock); + + int runId = await client.CreateTestRunAsync(new AzureDevOpsPublishConfiguration("https://dev.azure.com/org/", "project", "token", 1, "run", "tests.dll", "results"), CancellationToken.None); + + Assert.AreEqual(8, runId); + Assert.HasCount(1, task.DelayCalls); + Assert.AreEqual(TimeSpan.FromMilliseconds(500), task.DelayCalls[0]); + } + + [TestMethod] + public async Task ConsumeAsync_PublishFailureLogsWarningAndDoesNotThrow() + { + using TestDirectory directory = CreateTestDirectory(); + AzureDevOpsTestResultsPublisher publisher = CreatePublisher(directory.Path, options: new(1, TimeSpan.FromMinutes(1), 4, TimeSpan.FromMilliseconds(1)), out FakeAzureDevOpsTestResultsClient client, out FakeClock clock, out CollectingLogger logger); + client.CreateTestRunAsyncFunc = (_, _) => Task.FromResult(103); + client.PublishTestResultsAsyncFunc = (_, _, _, _) => Task.FromException(new JsonException("publish failed")); + + await StartPublisherAsync(publisher); + await publisher.ConsumeAsync(Mock.Of(), CreateMessage(CreateNode("test-1", new PassedTestNodeStateProperty(), clock.UtcNow)), CancellationToken.None); + await publisher.OnTestSessionFinishingAsync(new Microsoft.Testing.Platform.Services.TestSessionContext(CancellationToken.None)); + + Assert.Contains(AzureDevOpsResources.AzureDevOpsLivePublishingPublishResultsFailed, string.Join(Environment.NewLine, logger.Logs)); + } + + [TestMethod] + public async Task RunIdCoordinator_CreateAndReadFlowSharesRunIdAcrossProcesses() + { + using TestDirectory directory = CreateTestDirectory(); + FakeClock clock = new() { UtcNow = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero) }; + CollectingLogger logger = new(); + AzureDevOpsTestResultsPublisherOptions options = new(10, TimeSpan.FromSeconds(5), 10, TimeSpan.FromMilliseconds(1)); + Mock ownerEnvironment = CreateEnvironmentMock(processId: GetAliveProcessId()); + Mock joinerEnvironment = CreateEnvironmentMock(processId: int.MaxValue); + SystemFileSystem fileSystem = new(); + AzureDevOpsRunIdCoordinator ownerCoordinator = new(fileSystem, new SystemTask(), clock, ownerEnvironment.Object, logger, options); + AzureDevOpsRunIdCoordinator joinerCoordinator = new(fileSystem, new SystemTask(), clock, joinerEnvironment.Object, logger, options); + AzureDevOpsPublishConfiguration configuration = new("https://dev.azure.com/org/", "project", "token", 123, "run", "tests.dll", directory.Path); + + AzureDevOpsCoordinatedRun ownerRun = await ownerCoordinator.AcquireRunAsync(configuration, _ => Task.FromResult(88), CancellationToken.None); + AzureDevOpsCoordinatedRun joinerRun = await joinerCoordinator.AcquireRunAsync(configuration, _ => Task.FromResult(99), CancellationToken.None); + + Assert.AreEqual(88, ownerRun.RunId); + Assert.IsTrue(ownerRun.IsOwner); + Assert.AreEqual(88, joinerRun.RunId); + Assert.IsFalse(joinerRun.IsOwner); + Assert.IsTrue(File.Exists(Path.Combine(directory.Path, "azdo-runid.123.json"))); + } + + [TestMethod] + public async Task RunIdCoordinator_AcquireRunAsync_ReplacesExpiredOwnerAndRunIdFiles() + { + using TestDirectory directory = CreateTestDirectory(); + FakeClock clock = new() { UtcNow = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero) }; + CollectingLogger logger = new(); + AzureDevOpsTestResultsPublisherOptions options = new(10, TimeSpan.FromSeconds(5), 2, TimeSpan.FromMilliseconds(1)); + Mock environment = CreateEnvironmentMock(processId: GetAliveProcessId()); + SystemFileSystem fileSystem = new(); + AzureDevOpsRunIdCoordinator coordinator = new(fileSystem, new FakeTask(), clock, environment.Object, logger, options); + AzureDevOpsPublishConfiguration configuration = new("https://dev.azure.com/org/", "project", "token", 123, "run", "tests.dll", directory.Path); + + File.WriteAllText(Path.Combine(directory.Path, "azdo-runid.123.owner"), JsonSerializer.Serialize(new AzureDevOpsLeaseFile(int.MaxValue, 123, clock.UtcNow.AddMinutes(-1)))); + File.WriteAllText(Path.Combine(directory.Path, "azdo-runid.123.json"), JsonSerializer.Serialize(new AzureDevOpsRunIdFile(7, 123, configuration.CollectionUri, configuration.Project, clock.UtcNow.AddMinutes(-1)))); + + AzureDevOpsCoordinatedRun coordinatedRun = await coordinator.AcquireRunAsync(configuration, _ => Task.FromResult(88), CancellationToken.None); + + Assert.AreEqual(88, coordinatedRun.RunId); + Assert.IsTrue(coordinatedRun.IsOwner); + Assert.IsTrue(File.Exists(Path.Combine(directory.Path, "azdo-runid.123.json"))); + } + + [TestMethod] + public async Task RunIdCoordinator_AcquireRunAsync_OverwritesExistingParticipantFile() + { + using TestDirectory directory = CreateTestDirectory(); + FakeClock clock = new() { UtcNow = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero) }; + CollectingLogger logger = new(); + AzureDevOpsTestResultsPublisherOptions options = new(10, TimeSpan.FromSeconds(5), 2, TimeSpan.FromMilliseconds(1)); + int joinerProcessId = GetAliveProcessId(); + Mock joinerEnvironment = CreateEnvironmentMock(processId: joinerProcessId); + SystemFileSystem fileSystem = new(); + AzureDevOpsRunIdCoordinator joinerCoordinator = new(fileSystem, new FakeTask(), clock, joinerEnvironment.Object, logger, options); + AzureDevOpsPublishConfiguration configuration = new("https://dev.azure.com/org/", "project", "token", 123, "run", "tests.dll", directory.Path); + + File.WriteAllText(Path.Combine(directory.Path, "azdo-runid.123.owner"), JsonSerializer.Serialize(new AzureDevOpsLeaseFile(int.MaxValue, 123, clock.UtcNow.AddHours(1)))); + File.WriteAllText(Path.Combine(directory.Path, "azdo-runid.123.json"), JsonSerializer.Serialize(new AzureDevOpsRunIdFile(91, 123, configuration.CollectionUri, configuration.Project, clock.UtcNow.AddHours(1)))); + File.WriteAllText(Path.Combine(directory.Path, $"azdo-runid.123.participant.{joinerProcessId}.json"), "stale"); + + AzureDevOpsCoordinatedRun coordinatedRun = await joinerCoordinator.AcquireRunAsync(configuration, _ => Task.FromResult(0), CancellationToken.None); + AzureDevOpsLeaseFile? participantLease = JsonSerializer.Deserialize(File.ReadAllText(coordinatedRun.ParticipantFilePath)); + + Assert.AreEqual(91, coordinatedRun.RunId); + Assert.IsFalse(coordinatedRun.IsOwner); + Assert.IsNotNull(participantLease); + AzureDevOpsLeaseFile lease = participantLease!; + Assert.AreEqual(123, lease.BuildId); + Assert.IsGreaterThan(clock.UtcNow, lease.ExpiresAt); + } + + [TestMethod] + public async Task RunIdCoordinator_FinalizeRunAsync_TimesOutAndLogsWarning() + { + using TestDirectory directory = CreateTestDirectory(); + FakeClock clock = new() { UtcNow = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero) }; + CollectingLogger logger = new(); + FakeTask task = new(timeSpan => clock.UtcNow += timeSpan); + AzureDevOpsTestResultsPublisherOptions options = new(10, TimeSpan.FromSeconds(5), 5, TimeSpan.FromMilliseconds(10), TimeSpan.FromMilliseconds(30), TimeSpan.FromHours(4)); + int aliveProcessId = GetAliveProcessId(); + Mock environment = CreateEnvironmentMock(processId: aliveProcessId); + AzureDevOpsRunIdCoordinator coordinator = new(new SystemFileSystem(), task, clock, environment.Object, logger, options); + string ownerFilePath = Path.Combine(directory.Path, "azdo-runid.123.owner"); + string runIdFilePath = Path.Combine(directory.Path, "azdo-runid.123.json"); + string participantFilePath = Path.Combine(directory.Path, $"azdo-runid.123.participant.{int.MaxValue}.json"); + + File.WriteAllText(ownerFilePath, JsonSerializer.Serialize(new AzureDevOpsLeaseFile(aliveProcessId, 123, clock.UtcNow.AddHours(1)))); + File.WriteAllText(runIdFilePath, JsonSerializer.Serialize(new AzureDevOpsRunIdFile(5, 123, "https://dev.azure.com/org/", "project", clock.UtcNow.AddHours(1)))); + File.WriteAllText(Path.Combine(directory.Path, $"azdo-runid.123.participant.{aliveProcessId}.json"), JsonSerializer.Serialize(new AzureDevOpsLeaseFile(aliveProcessId, 123, clock.UtcNow.AddHours(1)))); + + int finalizeCalls = 0; + await coordinator.FinalizeRunAsync(new AzureDevOpsCoordinatedRun(5, true, 123, directory.Path, runIdFilePath, ownerFilePath, participantFilePath), _ => + { + finalizeCalls++; + return Task.CompletedTask; + }, CancellationToken.None); + + Assert.AreEqual(1, finalizeCalls); + Assert.Contains(log => log.IndexOf("timed out", StringComparison.OrdinalIgnoreCase) >= 0, logger.Logs); + Assert.IsFalse(File.Exists(ownerFilePath)); + Assert.IsFalse(File.Exists(runIdFilePath)); + } + + private static async Task StartPublisherAsync(AzureDevOpsTestResultsPublisher publisher) + { + Assert.IsTrue(await publisher.IsEnabledAsync()); + await publisher.OnTestSessionStartingAsync(new Microsoft.Testing.Platform.Services.TestSessionContext(CancellationToken.None)); + } + + private TestDirectory CreateTestDirectory() => new(_directoriesToDelete); + + private static int GetAliveProcessId() +#if NET + => Environment.ProcessId; +#else + => Process.GetCurrentProcess().Id; +#endif + + private static void TryDeleteDirectory(string path) + { + try + { + if (Directory.Exists(path)) + { + Directory.Delete(path, recursive: true); + } + } + catch (IOException) + { + } + catch (UnauthorizedAccessException) + { + } + } + + private static Mock CreateEnvironmentMock(int processId, string? stageName = "stage", string? jobName = "job") + { + Mock environment = new(); + environment.SetupGet(x => x.ProcessId).Returns(processId); + environment.SetupGet(x => x.MachineName).Returns("agent-name"); + environment.Setup(x => x.GetEnvironmentVariable("TF_BUILD")).Returns("true"); + environment.Setup(x => x.GetEnvironmentVariable("SYSTEM_COLLECTIONURI")).Returns("https://dev.azure.com/org/"); + environment.Setup(x => x.GetEnvironmentVariable("SYSTEM_TEAMPROJECT")).Returns("project"); + environment.Setup(x => x.GetEnvironmentVariable("SYSTEM_ACCESSTOKEN")).Returns("token"); + environment.Setup(x => x.GetEnvironmentVariable("BUILD_BUILDID")).Returns("123"); + environment.Setup(x => x.GetEnvironmentVariable("AGENT_NAME")).Returns("agent-name"); + environment.Setup(x => x.GetEnvironmentVariable("SYSTEM_STAGENAME")).Returns(stageName); + environment.Setup(x => x.GetEnvironmentVariable("SYSTEM_JOBNAME")).Returns(jobName); + return environment; + } + + private static AzureDevOpsTestResultsPublisher CreatePublisher( + string resultsDirectory, + AzureDevOpsTestResultsPublisherOptions options, + out FakeAzureDevOpsTestResultsClient client, + out FakeClock clock, + out CollectingLogger logger, + Mock? environment = null, + Mock? testApplicationModuleInfo = null, + ITask? task = null) + { + Mock commandLineOptions = new(); + commandLineOptions.Setup(x => x.IsOptionSet(AzureDevOpsCommandLineOptions.PublishAzureDevOpsTestResultsOptionName)).Returns(true); + string[]? runNameArguments = null; + commandLineOptions.Setup(x => x.TryGetOptionArgumentList(AzureDevOpsCommandLineOptions.PublishAzureDevOpsRunNameOptionName, out runNameArguments)).Returns(false); + + Mock configuration = new(); + configuration.Setup(x => x[PlatformConfigurationConstants.PlatformResultDirectory]).Returns(resultsDirectory); + + environment ??= CreateEnvironmentMock(processId: GetAliveProcessId()); + + testApplicationModuleInfo ??= new Mock(); + testApplicationModuleInfo.Setup(x => x.TryGetAssemblyName()).Returns("MyTests"); + testApplicationModuleInfo.Setup(x => x.GetCurrentTestApplicationFullPath()).Returns("Q:\\src\\testfx-worktrees\\azdo-live\\artifacts\\MyTests.dll"); + + Mock processExitCode = new(); + processExitCode.Setup(x => x.GetProcessExitCode()).Returns(0); + processExitCode.SetupGet(x => x.HasTestAdapterTestSessionFailure).Returns(false); + + client = new FakeAzureDevOpsTestResultsClient(); + clock = new FakeClock { UtcNow = new DateTimeOffset(2025, 1, 1, 12, 0, 0, TimeSpan.Zero) }; + logger = new CollectingLogger(); + + return new AzureDevOpsTestResultsPublisher( + commandLineOptions.Object, + configuration.Object, + environment.Object, + new SystemFileSystem(), + testApplicationModuleInfo.Object, + processExitCode.Object, + client, + task ?? new FakeTask(), + clock, + logger, + options); + } + + private static TestNodeUpdateMessage CreateMessage(TestNode node) + => new(new SessionUid(Guid.NewGuid().ToString()), node); + + private static TestNode CreateNode(string uid, IProperty state, DateTimeOffset startTime, TimingProperty? timing = null) + { + PropertyBag properties = timing is null ? new PropertyBag(state) : new PropertyBag(state, timing); + return new TestNode + { + Uid = new TestNodeUid(uid), + DisplayName = uid, + Properties = properties, + }; + } + + private sealed class FakeClock : IClock + { + public DateTimeOffset UtcNow { get; set; } + } + + private sealed class FakeAzureDevOpsTestResultsClient : IAzureDevOpsTestResultsClient + { + public Func> CreateTestRunAsyncFunc { get; set; } = (_, _) => Task.FromResult(0); + + public Func, CancellationToken, Task> PublishTestResultsAsyncFunc { get; set; } = (_, _, _, _) => Task.CompletedTask; + + public List<(AzureDevOpsPublishConfiguration Configuration, int RunId, string State)> UpdateTestRunStateCalls { get; } = []; + + public Task CreateTestRunAsync(AzureDevOpsPublishConfiguration configuration, CancellationToken cancellationToken) + => CreateTestRunAsyncFunc(configuration, cancellationToken); + + public Task PublishTestResultsAsync(AzureDevOpsPublishConfiguration configuration, int runId, IReadOnlyList results, CancellationToken cancellationToken) + => PublishTestResultsAsyncFunc(configuration, runId, results, cancellationToken); + + public Task UpdateTestRunStateAsync(AzureDevOpsPublishConfiguration configuration, int runId, string state, CancellationToken cancellationToken) + { + UpdateTestRunStateCalls.Add((configuration, runId, state)); + return Task.CompletedTask; + } + } + + private sealed class FakeTask(Action? delayCallback = null) : ITask + { + public List DelayCalls { get; } = []; + + public Task Delay(int millisecondDelay) + { + DelayCalls.Add(TimeSpan.FromMilliseconds(millisecondDelay)); + return Task.CompletedTask; + } + + public Task Delay(TimeSpan timeSpan, CancellationToken cancellationToken) + { + DelayCalls.Add(timeSpan); + delayCallback?.Invoke(timeSpan); + return Task.CompletedTask; + } + + public Task Run(Action action) + { + action(); + return Task.CompletedTask; + } + + public Task Run(Func function, CancellationToken cancellationToken) + => function(); + + public Task Run(Func?> function, CancellationToken cancellationToken) + => function()!; + + public Task RunLongRunning(Func action, string name, CancellationToken cancellationToken) + => action(); + + public Task WhenAll(params Task[] tasks) + => Task.WhenAll(tasks); + } + + private sealed class CollectingLogger : ILogger + { + public List Logs { get; } = []; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, TState state, Exception? exception, Func formatter) + => Logs.Add($"{logLevel}: {formatter(state, exception)}"); + + public Task LogAsync(LogLevel logLevel, TState state, Exception? exception, Func formatter) + { + Logs.Add($"{logLevel}: {formatter(state, exception)}"); + return Task.CompletedTask; + } + } + + private sealed class QueueHttpMessageHandler(params Func>[] responses) : HttpMessageHandler + { + private readonly Queue>> _responses = new(responses); + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + => _responses.Dequeue().Invoke(request, cancellationToken); + } + + private sealed class TestDirectory : IDisposable + { + public TestDirectory(ICollection trackedDirectories) + { + Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), nameof(AzureDevOpsLivePublishingTests), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path); + trackedDirectories.Add(Path); + } + + public string Path { get; } + + public void Dispose() + => TryDeleteDirectory(Path); + } +} From 70d72f2bf11df7057ebd19394a6e9a4d5f46b46a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 09:58:39 +0000 Subject: [PATCH 2/6] Address PR review comments: coordinator cleanup, timeout wrapping, run id validation, cleanup token, cross-platform path, resx typo Co-authored-by: Evangelink <11340282+Evangelink@users.noreply.github.com> --- .../AzureDevOpsRunIdCoordinator.cs | 8 ++- .../AzureDevOpsTestResultsClient.cs | 60 +++++++++++-------- .../AzureDevOpsTestResultsPublisher.cs | 5 +- .../Resources/AzureDevOpsResources.resx | 4 +- .../Resources/xlf/AzureDevOpsResources.cs.xlf | 6 +- .../Resources/xlf/AzureDevOpsResources.de.xlf | 6 +- .../Resources/xlf/AzureDevOpsResources.es.xlf | 6 +- .../Resources/xlf/AzureDevOpsResources.fr.xlf | 6 +- .../Resources/xlf/AzureDevOpsResources.it.xlf | 6 +- .../Resources/xlf/AzureDevOpsResources.ja.xlf | 6 +- .../Resources/xlf/AzureDevOpsResources.ko.xlf | 6 +- .../Resources/xlf/AzureDevOpsResources.pl.xlf | 6 +- .../xlf/AzureDevOpsResources.pt-BR.xlf | 6 +- .../Resources/xlf/AzureDevOpsResources.ru.xlf | 6 +- .../Resources/xlf/AzureDevOpsResources.tr.xlf | 6 +- .../xlf/AzureDevOpsResources.zh-Hans.xlf | 6 +- .../xlf/AzureDevOpsResources.zh-Hant.xlf | 6 +- .../AzureDevOpsLivePublishingTests.cs | 2 +- 18 files changed, 88 insertions(+), 69 deletions(-) diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsRunIdCoordinator.cs b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsRunIdCoordinator.cs index 3269b701ca..d79a45e039 100644 --- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsRunIdCoordinator.cs +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsRunIdCoordinator.cs @@ -90,8 +90,12 @@ public async Task AcquireRunAsync(AzureDevOpsPublishC catch { TryDeleteFile(participantFilePath); - TryDeleteFile(runIdFilePath); - TryDeleteFile(ownerFilePath); + if (ownsOwnerFile) + { + TryDeleteFile(runIdFilePath); + TryDeleteFile(ownerFilePath); + } + throw; } } diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsTestResultsClient.cs b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsTestResultsClient.cs index 8b52162607..32b6e55a98 100644 --- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsTestResultsClient.cs +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsTestResultsClient.cs @@ -53,7 +53,9 @@ public async Task CreateTestRunAsync(AzureDevOpsPublishConfiguration config new CreateTestRunRequest(configuration.RunName, true, new BuildReference(configuration.BuildId), AzureDevOpsLivePublishingConstants.InProgressTestRunState)); CreateTestRunResponse response = await SendAsync(request, cancellationToken).ConfigureAwait(false); - return response.Id; + return response.Id > 0 + ? response.Id + : throw new InvalidOperationException(AzureDevOpsResources.AzureDevOpsLivePublishingInvalidResponse); } public Task PublishTestResultsAsync(AzureDevOpsPublishConfiguration configuration, int runId, IReadOnlyList results, CancellationToken cancellationToken) @@ -141,37 +143,47 @@ private async Task SendCoreAsync(HttpRequestMessage request { Exception? lastException = null; - for (int attempt = 1; attempt <= MaxAttempts; attempt++) + try { - using HttpRequestMessage currentRequest = await CloneAsync(request, requestCancellationToken).ConfigureAwait(false); - using var attemptTimeoutSource = CancellationTokenSource.CreateLinkedTokenSource(requestCancellationToken); - attemptTimeoutSource.CancelAfter(AttemptTimeout); - - try + for (int attempt = 1; attempt <= MaxAttempts; attempt++) { - HttpResponseMessage response = await _httpClient.SendAsync(currentRequest, attemptTimeoutSource.Token).ConfigureAwait(false); - if (response.IsSuccessStatusCode) - { - return response; - } + using HttpRequestMessage currentRequest = await CloneAsync(request, requestCancellationToken).ConfigureAwait(false); + using var attemptTimeoutSource = CancellationTokenSource.CreateLinkedTokenSource(requestCancellationToken); + attemptTimeoutSource.CancelAfter(AttemptTimeout); - if (!ShouldRetry(response.StatusCode, attempt)) + try { - string responseBody = await ReadAsStringAsync(response.Content, requestCancellationToken).ConfigureAwait(false); + HttpResponseMessage response = await _httpClient.SendAsync(currentRequest, attemptTimeoutSource.Token).ConfigureAwait(false); + if (response.IsSuccessStatusCode) + { + return response; + } + + if (!ShouldRetry(response.StatusCode, attempt)) + { + 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(); - throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, AzureDevOpsResources.AzureDevOpsLivePublishingHttpError, (int)response.StatusCode, responseBody)); + await _task.Delay(delay, requestCancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (ShouldRetry(ex, userCancellationToken, requestCancellationToken, attempt)) + { + lastException = ex; + await _task.Delay(GetExponentialBackoffDelay(attempt), requestCancellationToken).ConfigureAwait(false); } - - TimeSpan delay = GetDelay(response, attempt); - response.Dispose(); - await _task.Delay(delay, requestCancellationToken).ConfigureAwait(false); - } - catch (Exception ex) when (ShouldRetry(ex, userCancellationToken, requestCancellationToken, attempt)) - { - lastException = ex; - await _task.Delay(GetExponentialBackoffDelay(attempt), requestCancellationToken).ConfigureAwait(false); } } + catch (OperationCanceledException) when (!userCancellationToken.IsCancellationRequested) + { + // An internal timeout (per-attempt or request-level) fired on the final retry attempt. + // Convert to a non-cancellation exception so publishing failures never propagate as + // OperationCanceledException and fault the data consumer. + throw new InvalidOperationException(AzureDevOpsResources.AzureDevOpsLivePublishingRequestFailed, lastException); + } throw new InvalidOperationException(AzureDevOpsResources.AzureDevOpsLivePublishingRequestFailed, lastException); } diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsTestResultsPublisher.cs b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsTestResultsPublisher.cs index 82837eeb93..1f09f5783b 100644 --- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsTestResultsPublisher.cs +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsTestResultsPublisher.cs @@ -193,10 +193,13 @@ public async Task OnTestSessionFinishingAsync(ITestSessionContext testSessionCon try { + // Use a fresh, non-canceled token so finalization (marking the run Aborted/Completed) + // succeeds even when the test session itself has been canceled. + using var cleanupCts = new CancellationTokenSource(_options.CoordinationFinalizeTimeout + TimeSpan.FromSeconds(60)); await _runIdCoordinator.FinalizeRunAsync( _coordinatedRun, cancellationToken => _client.UpdateTestRunStateAsync(_publishConfiguration, CurrentRunId.Value, finalState, cancellationToken), - testSessionContext.CancellationToken).ConfigureAwait(false); + cleanupCts.Token).ConfigureAwait(false); } catch (Exception ex) when (ex is not OperationCanceledException) { diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/AzureDevOpsResources.resx b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/AzureDevOpsResources.resx index 09098b8684..d3b641204f 100644 --- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/AzureDevOpsResources.resx +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/AzureDevOpsResources.resx @@ -122,7 +122,7 @@ {Locked='--report-azdo-severity'}{Locked='--report-azdo'} - Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. + Azure DevOps report generator to write errors to the output in a way that Azure DevOps understands. Azure DevOps report generator @@ -131,7 +131,7 @@ Invalid option {0}. - Enable Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. + Enable Azure DevOps report generator to write errors to the output in a way that Azure DevOps understands. Severity to use for the reported event. Options are: error (default) and warning. diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.cs.xlf b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.cs.xlf index 51a1fe030c..8709f804cf 100644 --- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.cs.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.cs.xlf @@ -73,8 +73,8 @@ {Locked='--report-azdo-severity'}{Locked='--report-azdo'} - Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. - Generátor sestav Azure DevOps pro zápis chyb do výstupu způsobem, který je pro AzureDev Ops srozumitelný. + Azure DevOps report generator to write errors to the output in a way that Azure DevOps understands. + Generátor sestav Azure DevOps pro zápis chyb do výstupu způsobem, který je pro AzureDev Ops srozumitelný. @@ -88,7 +88,7 @@ - Enable Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. + Enable Azure DevOps report generator to write errors to the output in a way that Azure DevOps understands. Umožňuje generátoru sestav Azure DevOps zapisovat chyby do výstupu způsobem, který je pro AzureDev Ops srozumitelný. diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.de.xlf b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.de.xlf index eaa37b4983..c2e53aaf2f 100644 --- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.de.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.de.xlf @@ -73,8 +73,8 @@ {Locked='--report-azdo-severity'}{Locked='--report-azdo'} - Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. - Azure DevOps-Berichts-Generator, um Fehler auf eine Weise in die Ausgabe zu schreiben, die AzureDev Ops versteht. + Azure DevOps report generator to write errors to the output in a way that Azure DevOps understands. + Azure DevOps-Berichts-Generator, um Fehler auf eine Weise in die Ausgabe zu schreiben, die AzureDev Ops versteht. @@ -88,7 +88,7 @@ - Enable Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. + Enable Azure DevOps report generator to write errors to the output in a way that Azure DevOps understands. Azure DevOps-Berichts-Generator aktivieren, um Fehler auf eine Weise in die Ausgabe zu schreiben, die AzureDev Ops versteht. diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.es.xlf b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.es.xlf index 90bf6f0606..4be488b60a 100644 --- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.es.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.es.xlf @@ -73,8 +73,8 @@ {Locked='--report-azdo-severity'}{Locked='--report-azdo'} - Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. - El generador de informes de Azure DevOps escribe errores en la salida de una manera que AzureDev Ops comprende. + Azure DevOps report generator to write errors to the output in a way that Azure DevOps understands. + El generador de informes de Azure DevOps escribe errores en la salida de una manera que AzureDev Ops comprende. @@ -88,7 +88,7 @@ - Enable Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. + Enable Azure DevOps report generator to write errors to the output in a way that Azure DevOps understands. Habilite el generador de informes de Azure DevOps para escribir errores en la salida de una manera que AzureDev Ops comprenda. diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.fr.xlf b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.fr.xlf index 7d07ef16c2..9358deebd1 100644 --- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.fr.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.fr.xlf @@ -73,8 +73,8 @@ {Locked='--report-azdo-severity'}{Locked='--report-azdo'} - Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. - Générateur de rapports Azure DevOps pour écrire les erreurs dans la sortie d’une manière comprise par Azure DevOps. + Azure DevOps report generator to write errors to the output in a way that Azure DevOps understands. + Générateur de rapports Azure DevOps pour écrire les erreurs dans la sortie d’une manière comprise par Azure DevOps. @@ -88,7 +88,7 @@ - Enable Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. + Enable Azure DevOps report generator to write errors to the output in a way that Azure DevOps understands. Activez le générateur de rapports Azure DevOps pour écrire les erreurs dans la sortie d’une manière comprise par Azure DevOps. diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.it.xlf b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.it.xlf index bb900974f8..5ffeb707cb 100644 --- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.it.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.it.xlf @@ -73,8 +73,8 @@ {Locked='--report-azdo-severity'}{Locked='--report-azdo'} - Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. - Il generatore di report di Azure DevOps per scrivere gli errori nell'output in un modo che Azure DevOps comprenda. + Azure DevOps report generator to write errors to the output in a way that Azure DevOps understands. + Il generatore di report di Azure DevOps per scrivere gli errori nell'output in un modo che Azure DevOps comprenda. @@ -88,7 +88,7 @@ - Enable Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. + Enable Azure DevOps report generator to write errors to the output in a way that Azure DevOps understands. Abilitare il generatore di report di Azure DevOps per scrivere gli errori nell'output in un modo che Azure DevOps comprenda. diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.ja.xlf b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.ja.xlf index 461b82a0f2..a58d714021 100644 --- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.ja.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.ja.xlf @@ -73,8 +73,8 @@ {Locked='--report-azdo-severity'}{Locked='--report-azdo'} - Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. - AzureDev Ops が理解する方法で出力にエラーを書き込む Azure DevOps レポート生成プログラム。 + Azure DevOps report generator to write errors to the output in a way that Azure DevOps understands. + AzureDev Ops が理解する方法で出力にエラーを書き込む Azure DevOps レポート生成プログラム。 @@ -88,7 +88,7 @@ - Enable Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. + Enable Azure DevOps report generator to write errors to the output in a way that Azure DevOps understands. Azure DevOps レポート生成プログラムを有効にして、AzureDev Ops が理解する方法で出力にエラーを書き込みます。 diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.ko.xlf b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.ko.xlf index fcaf5110d9..10189551eb 100644 --- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.ko.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.ko.xlf @@ -73,8 +73,8 @@ {Locked='--report-azdo-severity'}{Locked='--report-azdo'} - Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. - Azure DevOps가 이해할 수 있는 방식으로 출력에 오류를 기록하는 Azure DevOps 보고서 생성기입니다. + Azure DevOps report generator to write errors to the output in a way that Azure DevOps understands. + Azure DevOps가 이해할 수 있는 방식으로 출력에 오류를 기록하는 Azure DevOps 보고서 생성기입니다. @@ -88,7 +88,7 @@ - Enable Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. + Enable Azure DevOps report generator to write errors to the output in a way that Azure DevOps understands. Azure DevOps 보고서 생성기를 활성화하여 Azure DevOps가 이해할 수 있는 방식으로 출력에 오류를 기록합니다. diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.pl.xlf b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.pl.xlf index 4f879f783c..90c76a7bdd 100644 --- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.pl.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.pl.xlf @@ -73,8 +73,8 @@ {Locked='--report-azdo-severity'}{Locked='--report-azdo'} - Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. - Generator raportów usługi Azure DevOps umożliwiający zapisywanie błędów w danych wyjściowych w sposób zrozumiały dla usługi Azure DevOps. + Azure DevOps report generator to write errors to the output in a way that Azure DevOps understands. + Generator raportów usługi Azure DevOps umożliwiający zapisywanie błędów w danych wyjściowych w sposób zrozumiały dla usługi Azure DevOps. @@ -88,7 +88,7 @@ - Enable Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. + Enable Azure DevOps report generator to write errors to the output in a way that Azure DevOps understands. Włącz generator raportów usługi Azure DevOps, aby zapisywać błędy w danych wyjściowych w sposób zrozumiały dla usługi Azure DevOps. diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.pt-BR.xlf b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.pt-BR.xlf index 21302a6994..f8f258ad16 100644 --- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.pt-BR.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.pt-BR.xlf @@ -73,8 +73,8 @@ {Locked='--report-azdo-severity'}{Locked='--report-azdo'} - Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. - Gerador de relatórios do Azure DevOps para gravar erros na saída de uma forma que o Azure DevOps entenda. + Azure DevOps report generator to write errors to the output in a way that Azure DevOps understands. + Gerador de relatórios do Azure DevOps para gravar erros na saída de uma forma que o Azure DevOps entenda. @@ -88,7 +88,7 @@ - Enable Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. + Enable Azure DevOps report generator to write errors to the output in a way that Azure DevOps understands. Habilitar o gerador de relatórios do Azure DevOps para gravar erros na saída de uma forma que o Azure DevOps entenda. diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.ru.xlf b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.ru.xlf index 45dc2fac2c..0180172ff3 100644 --- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.ru.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.ru.xlf @@ -73,8 +73,8 @@ {Locked='--report-azdo-severity'}{Locked='--report-azdo'} - Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. - Генератор отчетов Azure DevOps для записи ошибок в выходные данные в формате, который понимает Azure DevOps. + Azure DevOps report generator to write errors to the output in a way that Azure DevOps understands. + Генератор отчетов Azure DevOps для записи ошибок в выходные данные в формате, который понимает Azure DevOps. @@ -88,7 +88,7 @@ - Enable Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. + Enable Azure DevOps report generator to write errors to the output in a way that Azure DevOps understands. Включите генератор отчетов Azure DevOps для записи ошибок в выходные данные в формате, который понимает Azure DevOps. diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.tr.xlf b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.tr.xlf index b47bb4894f..19d7f729e2 100644 --- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.tr.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.tr.xlf @@ -73,8 +73,8 @@ {Locked='--report-azdo-severity'}{Locked='--report-azdo'} - Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. - Hataları Azure DevOps'un anlayacağı şekilde çıktıya yazan Azure DevOps rapor oluşturucusu. + Azure DevOps report generator to write errors to the output in a way that Azure DevOps understands. + Hataları Azure DevOps'un anlayacağı şekilde çıktıya yazan Azure DevOps rapor oluşturucusu. @@ -88,7 +88,7 @@ - Enable Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. + Enable Azure DevOps report generator to write errors to the output in a way that Azure DevOps understands. Azure DevOps rapor oluşturucusunu, hataları Azure DevOps'un anlayacağı şekilde çıktıya yazması için etkinleştirin. diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.zh-Hans.xlf b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.zh-Hans.xlf index 4e0f8260ac..81685b8009 100644 --- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.zh-Hans.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.zh-Hans.xlf @@ -73,8 +73,8 @@ {Locked='--report-azdo-severity'}{Locked='--report-azdo'} - Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. - Azure DevOps报表生成器,可通过 Azure DevOps 可理解的方式将错误写入输出。 + Azure DevOps report generator to write errors to the output in a way that Azure DevOps understands. + Azure DevOps报表生成器,可通过 Azure DevOps 可理解的方式将错误写入输出。 @@ -88,7 +88,7 @@ - Enable Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. + Enable Azure DevOps report generator to write errors to the output in a way that Azure DevOps understands. 启用 Azure DevOps 报表生成器,以通过 Azure DevOps 可理解的方式将错误写入输出。 diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.zh-Hant.xlf b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.zh-Hant.xlf index c179e68e2c..f724b78177 100644 --- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.zh-Hant.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/xlf/AzureDevOpsResources.zh-Hant.xlf @@ -73,8 +73,8 @@ {Locked='--report-azdo-severity'}{Locked='--report-azdo'} - Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. - Azure DevOps 報告產生器以 Azure DevOps 可理解的方式將錯誤寫入輸出。 + Azure DevOps report generator to write errors to the output in a way that Azure DevOps understands. + Azure DevOps 報告產生器以 Azure DevOps 可理解的方式將錯誤寫入輸出。 @@ -88,7 +88,7 @@ - Enable Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. + Enable Azure DevOps report generator to write errors to the output in a way that Azure DevOps understands. 啟用 Azure DevOps 報告產生器,以 Azure DevOps 可理解的方式將錯誤寫入輸出。 diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/AzureDevOpsLivePublishingTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/AzureDevOpsLivePublishingTests.cs index a22eababe8..a8f9197c23 100644 --- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/AzureDevOpsLivePublishingTests.cs +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/AzureDevOpsLivePublishingTests.cs @@ -436,7 +436,7 @@ private static AzureDevOpsTestResultsPublisher CreatePublisher( testApplicationModuleInfo ??= new Mock(); testApplicationModuleInfo.Setup(x => x.TryGetAssemblyName()).Returns("MyTests"); - testApplicationModuleInfo.Setup(x => x.GetCurrentTestApplicationFullPath()).Returns("Q:\\src\\testfx-worktrees\\azdo-live\\artifacts\\MyTests.dll"); + testApplicationModuleInfo.Setup(x => x.GetCurrentTestApplicationFullPath()).Returns(Path.Combine("testfx-worktrees", "azdo-live", "artifacts", "MyTests.dll")); Mock processExitCode = new(); processExitCode.Setup(x => x.GetProcessExitCode()).Returns(0); From 95e4e06f5f847563b035b2ea3fc6cebd83e02ee6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 19:31:16 +0000 Subject: [PATCH 3/6] Address 3rd-round review comments: fix acceptance test strings, catch OCE in flush, add background flush loop Co-authored-by: Evangelink <11340282+Evangelink@users.noreply.github.com> --- .../AzureDevOpsTestResultsPublisher.cs | 64 ++++++++++++++++++- .../HelpInfoAllExtensionsTests.cs | 6 +- 2 files changed, 65 insertions(+), 5 deletions(-) diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsTestResultsPublisher.cs b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsTestResultsPublisher.cs index 1f09f5783b..2e98063d34 100644 --- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsTestResultsPublisher.cs +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsTestResultsPublisher.cs @@ -36,6 +36,8 @@ internal sealed class AzureDevOpsTestResultsPublisher : IDataConsumer, ITestSess private AzureDevOpsRunIdCoordinator? _runIdCoordinator; private AzureDevOpsCoordinatedRun? _coordinatedRun; private DateTimeOffset _lastFlushTime; + private CancellationTokenSource? _backgroundFlushCts; + private Task? _backgroundFlushTask; private int? CurrentRunId { get; set; } @@ -94,7 +96,11 @@ internal AzureDevOpsTestResultsPublisher( internal int? RunId => CurrentRunId; public void Dispose() - => _flushSemaphore.Dispose(); + { + _backgroundFlushCts?.Cancel(); + _backgroundFlushCts?.Dispose(); + _flushSemaphore.Dispose(); + } public Task IsEnabledAsync() { @@ -133,6 +139,11 @@ public async Task OnTestSessionStartingAsync(ITestSessionContext testSessionCont cancellationToken => _client.CreateTestRunAsync(_publishConfiguration, cancellationToken), testSessionContext.CancellationToken).ConfigureAwait(false); CurrentRunId = _coordinatedRun.RunId; + + // Start a background loop that flushes pending results on the time-based interval even + // when no new TestNodeUpdateMessages arrive (e.g. at the tail end of a slow test run). + _backgroundFlushCts = new CancellationTokenSource(); + _backgroundFlushTask = Task.Run(() => BackgroundFlushLoopAsync(_backgroundFlushCts.Token)); } catch (Exception ex) when (ex is not OperationCanceledException) { @@ -177,12 +188,33 @@ public async Task OnTestSessionFinishingAsync(ITestSessionContext testSessionCon return; } + // Stop the background flush loop before doing the session-end forced flush so there is no + // concurrent flush in flight when we drain the last batch. + if (_backgroundFlushCts is not null && _backgroundFlushTask is not null) + { +#pragma warning disable VSTHRD103 // CancelAsync is only available on .NET 8+; multi-target sync cancel is acceptable here. + _backgroundFlushCts.Cancel(); +#pragma warning restore VSTHRD103 + try + { + await _backgroundFlushTask.ConfigureAwait(false); + } + catch + { + // Ignore any exception from the background task — it's best-effort. + } + } + try { await _runIdCoordinator.RenewLeaseAsync(_coordinatedRun, testSessionContext.CancellationToken).ConfigureAwait(false); await FlushPendingResultsAsync(force: true, testSessionContext.CancellationToken).ConfigureAwait(false); } - catch (Exception ex) when (ex is not OperationCanceledException) + catch (OperationCanceledException) + { + // Best-effort flush: session was canceled; finalization still runs below with a fresh token. + } + catch (Exception ex) { _logger.LogWarning($"{AzureDevOpsResources.AzureDevOpsLivePublishingPublishResultsFailed} {ex.Message}"); } @@ -232,6 +264,34 @@ await _runIdCoordinator.FinalizeRunAsync( }; } + private async Task BackgroundFlushLoopAsync(CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + try + { + await Task.Delay(_options.FlushInterval, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + return; + } + + try + { + await FlushPendingResultsAsync(force: false, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + return; + } + catch (Exception ex) + { + _logger.LogWarning($"{AzureDevOpsResources.AzureDevOpsLivePublishingPublishResultsFailed} {ex.Message}"); + } + } + } + private async Task FlushPendingResultsAsync(bool force, CancellationToken cancellationToken) { if (_publishConfiguration is null || CurrentRunId is null) diff --git a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/HelpInfoAllExtensionsTests.cs b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/HelpInfoAllExtensionsTests.cs index 4b370de5a4..1368126f1c 100644 --- a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/HelpInfoAllExtensionsTests.cs +++ b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/HelpInfoAllExtensionsTests.cs @@ -105,7 +105,7 @@ Output verbosity when reporting tests. --publish-azdo-test-results Publish test results live to the Azure DevOps Tests tab. --report-azdo - Enable Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. + Enable Azure DevOps report generator to write errors to the output in a way that Azure DevOps understands. --report-azdo-severity Severity to use for the reported event. Options are: error (default) and warning. --report-trx @@ -357,7 +357,7 @@ Default type is 'Full' AzureDevOpsCommandLineProvider Name: Azure DevOps report generator Version: * - Description: Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. + Description: Azure DevOps report generator to write errors to the output in a way that Azure DevOps understands. Options: --publish-azdo-run-name Arity: 1 @@ -370,7 +370,7 @@ Default type is 'Full' --report-azdo Arity: 0 Hidden: False - Description: Enable Azure DevOps report generator to write errors to the output in a way that AzureDev Ops understands. + Description: Enable Azure DevOps report generator to write errors to the output in a way that Azure DevOps understands. --report-azdo-severity Arity: 1 Hidden: False From f71e65789005256d7910942f1d5853dcfe24e74c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 19:33:17 +0000 Subject: [PATCH 4/6] Polish: document Dispose lifecycle, log background task failure, restore OCE filter on second catch Co-authored-by: Evangelink <11340282+Evangelink@users.noreply.github.com> --- .../AzureDevOpsTestResultsPublisher.cs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsTestResultsPublisher.cs b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsTestResultsPublisher.cs index 2e98063d34..7229552a68 100644 --- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsTestResultsPublisher.cs +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsTestResultsPublisher.cs @@ -97,7 +97,12 @@ internal AzureDevOpsTestResultsPublisher( public void Dispose() { + // Signal the background flush loop to stop. The loop is already awaited in + // OnTestSessionFinishingAsync; this Cancel is a safety net for cases where the + // session lifecycle methods are not called (e.g. early disposal in tests). +#pragma warning disable VSTHRD103 // CancelAsync is only available on .NET 8+; synchronous cancel is acceptable in Dispose. _backgroundFlushCts?.Cancel(); +#pragma warning restore VSTHRD103 _backgroundFlushCts?.Dispose(); _flushSemaphore.Dispose(); } @@ -199,9 +204,14 @@ public async Task OnTestSessionFinishingAsync(ITestSessionContext testSessionCon { await _backgroundFlushTask.ConfigureAwait(false); } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // Unexpected failure in the background flush loop; the loop already logs per-flush warnings. + _logger.LogWarning($"{AzureDevOpsResources.AzureDevOpsLivePublishingPublishResultsFailed} {ex.Message}"); + } catch { - // Ignore any exception from the background task — it's best-effort. + // Cancellation — expected and fine. } } @@ -214,7 +224,7 @@ public async Task OnTestSessionFinishingAsync(ITestSessionContext testSessionCon { // Best-effort flush: session was canceled; finalization still runs below with a fresh token. } - catch (Exception ex) + catch (Exception ex) when (ex is not OperationCanceledException) { _logger.LogWarning($"{AzureDevOpsResources.AzureDevOpsLivePublishingPublishResultsFailed} {ex.Message}"); } From 363fc60a5e5e3959cbaf5f8970c3c5ecf273d1e2 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Mon, 18 May 2026 10:10:32 +0200 Subject: [PATCH 5/6] Fix AreAllDistinct comparer rendering to match test expectations Use comparer.GetType().Name (simple name) instead of .ToString() (full name) so the rendered diagnostic message matches the AssertTests.AreAll.cs expectations and is consistent with Assert.Contains. This resolves the failing AreAllDistinct_*_WithComparer_HasDuplicate_ShouldFail tests in CI. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../TestFramework/Assertions/Assert.AreAllDistinct.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/TestFramework/TestFramework/Assertions/Assert.AreAllDistinct.cs b/src/TestFramework/TestFramework/Assertions/Assert.AreAllDistinct.cs index 0929bfe3dc..183ac9d884 100644 --- a/src/TestFramework/TestFramework/Assertions/Assert.AreAllDistinct.cs +++ b/src/TestFramework/TestFramework/Assertions/Assert.AreAllDistinct.cs @@ -67,7 +67,7 @@ public static void AreAllDistinct([NotNull] IEnumerable? collection, [NotN { CheckParameterNotNull(collection, "Assert.AreAllDistinct", "collection"); CheckParameterNotNull(comparer, "Assert.AreAllDistinct", "comparer"); - AreAllDistinctImpl(collection, comparer, comparerTypeName: comparer.GetType().ToString(), message, collectionExpression); + AreAllDistinctImpl(collection, comparer, comparerTypeName: comparer.GetType().Name, message, collectionExpression); } /// @@ -121,7 +121,7 @@ public static void AreAllDistinct([NotNull] IEnumerable? collection, [NotNull] I { CheckParameterNotNull(collection, "Assert.AreAllDistinct", "collection"); CheckParameterNotNull(comparer, "Assert.AreAllDistinct", "comparer"); - AreAllDistinctImpl(collection.Cast(), new NonGenericEqualityComparerAdapter(comparer), comparerTypeName: comparer.GetType().ToString(), message, collectionExpression); + AreAllDistinctImpl(collection.Cast(), new NonGenericEqualityComparerAdapter(comparer), comparerTypeName: comparer.GetType().Name, message, collectionExpression); } #pragma warning disable CS8714 // The type cannot be used as type parameter in the generic type or method. Nullability of type argument doesn't match 'notnull' constraint. From e7f511a82668ee97c8e32bb7033a1fd83bc8764e Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Mon, 18 May 2026 15:42:04 +0200 Subject: [PATCH 6/6] Address PR review comments: run-state semantics, lease race, joiner wait C10 (run-state semantics): stop marking the Azure DevOps run as 'Aborted' just because individual tests failed. ExitCode.Success and ExitCode.AtLeastOneTestFailed are both normal completions; anything else (TestSessionAborted, TestHostProcessExitedNonGracefully, TestAdapterTestSessionFailure, MinimumExpectedTestsPolicyViolation, ...) plus session cancellation or HasTestAdapterTestSessionFailure now mark the run as Aborted. Embeds ExitCodes.cs as a shared source file matching the TrxReport extension. C11 (lease-read race): replace nullable TryReadLeaseFile with a tri-state LeaseReadResult (NotFound/Active/Expired/TransientReadError). TryAcquireOwnerAsync only deletes/clobbers the owner file on NotFound or Expired; an unreadable file (sharing-violation/partial write/garbage JSON) is treated as an active lease so two processes can't both create runs. CleanupStaleParticipants applies the same logic for participant files. C12 (joiner give-up): WaitForRunIdFileAsync now also takes the owner file path. After the initial CoordinationReadRetryCount budget, it keeps polling while the owner lease still looks Active/TransientReadError (or Expired with the owner PID still alive), capped by the new CoordinationJoinerMaxWaitTime option (default 2 minutes). A healthy slow CreateTestRunAsync no longer makes joiners abandon and silently stop publishing. New regression tests cover Completed-vs-Aborted state mapping for failing/aborted/canceled/adapter-failure exit paths, unreadable owner-file preservation, joiner waiting through an exhausted retry budget while owner lease is valid, and joiner take-over when the owner lease has expired with a dead PID. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AzureDevOpsLivePublishingModels.cs | 29 ++- .../AzureDevOpsRunIdCoordinator.cs | 87 +++++++-- .../AzureDevOpsTestResultsPublisher.cs | 15 +- ...esting.Extensions.AzureDevOpsReport.csproj | 1 + .../AzureDevOpsLivePublishingTests.cs | 171 +++++++++++++++++- 5 files changed, 277 insertions(+), 26 deletions(-) diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsLivePublishingModels.cs b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsLivePublishingModels.cs index 9815507a10..b939985146 100644 --- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsLivePublishingModels.cs +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsLivePublishingModels.cs @@ -43,12 +43,35 @@ internal sealed record AzureDevOpsTestResultsPublisherOptions( int CoordinationReadRetryCount, TimeSpan CoordinationReadRetryDelay, TimeSpan CoordinationFinalizeTimeout, - TimeSpan CoordinationFileExpiration) + 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)) + : this(batchSize, flushInterval, coordinationReadRetryCount, coordinationReadRetryDelay, TimeSpan.FromSeconds(30), TimeSpan.FromHours(4), TimeSpan.FromMinutes(2)) { } - public static AzureDevOpsTestResultsPublisherOptions Default { get; } = new(100, TimeSpan.FromSeconds(5), 40, TimeSpan.FromMilliseconds(250), TimeSpan.FromSeconds(30), TimeSpan.FromHours(4)); + 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 +{ + /// The lease file is not present on disk. + NotFound, + + /// The lease file was parsed and the lease is still valid. + Active, + + /// The lease file was parsed and the lease has expired. + Expired, + + /// The lease file is present but could not be read or parsed; it may be mid-write by another process. + TransientReadError, } + +internal readonly record struct LeaseReadResult(LeaseFileStatus Status, AzureDevOpsLeaseFile? Lease); diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsRunIdCoordinator.cs b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsRunIdCoordinator.cs index d79a45e039..79233269d1 100644 --- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsRunIdCoordinator.cs +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsRunIdCoordinator.cs @@ -58,7 +58,7 @@ public async Task AcquireRunAsync(AzureDevOpsPublishC return new AzureDevOpsCoordinatedRun(runId, true, configuration.BuildId, configuration.ResultsDirectory, runIdFilePath, ownerFilePath, participantFilePath); } - AzureDevOpsRunIdFile? runIdFile = await WaitForRunIdFileAsync(runIdFilePath, configuration.BuildId, cancellationToken).ConfigureAwait(false); + AzureDevOpsRunIdFile? runIdFile = await WaitForRunIdFileAsync(runIdFilePath, ownerFilePath, configuration.BuildId, cancellationToken).ConfigureAwait(false); if (runIdFile is null) { ownsOwnerFile = await TryAcquireOwnerAsync(ownerFilePath, configuration.BuildId, cancellationToken).ConfigureAwait(false); @@ -69,7 +69,7 @@ public async Task AcquireRunAsync(AzureDevOpsPublishC return new AzureDevOpsCoordinatedRun(runId, true, configuration.BuildId, configuration.ResultsDirectory, runIdFilePath, ownerFilePath, participantFilePath); } - runIdFile = await WaitForRunIdFileAsync(runIdFilePath, configuration.BuildId, cancellationToken).ConfigureAwait(false); + runIdFile = await WaitForRunIdFileAsync(runIdFilePath, ownerFilePath, configuration.BuildId, cancellationToken).ConfigureAwait(false); } if (runIdFile is not null @@ -157,15 +157,32 @@ private string[] CleanupStaleParticipants(string[] participantFiles) foreach (string participantFile in participantFiles) { - AzureDevOpsLeaseFile? lease = TryReadLeaseFile(participantFile); - if (lease is not null) + LeaseReadResult result = ReadLease(participantFile); + + // Treat a participant whose file we couldn't read (e.g. mid-write) as active to avoid + // racing with a process that is still updating its lease. + if (result.Status is LeaseFileStatus.TransientReadError or LeaseFileStatus.Active) { - if (lease.ExpiresAt > _clock.UtcNow && IsProcessAlive(lease.ProcessId)) + AzureDevOpsLeaseFile? lease = result.Lease; + if (lease is null || (lease.ExpiresAt > _clock.UtcNow && IsProcessAlive(lease.ProcessId))) { activeParticipants.Add(participantFile); continue; } } + else if (result.Status == LeaseFileStatus.Expired + && result.Lease is { } expiredLease + && IsProcessAlive(expiredLease.ProcessId)) + { + // Lease has expired according to wall-clock but the participant process is still + // running (its renewal may simply be stuck). Keep waiting rather than deleting. + activeParticipants.Add(participantFile); + continue; + } + else if (result.Status == LeaseFileStatus.NotFound) + { + continue; + } else if (TryGetPid(participantFile) is int processId && IsProcessAlive(processId)) { activeParticipants.Add(participantFile); @@ -197,9 +214,11 @@ private static bool IsProcessAlive(int processId) [UnconditionalSuppressMessage("ReflectionAnalysis", "IL2026", Justification = "The coordination payload type is internal, fixed, and controlled by this extension.")] [UnconditionalSuppressMessage("Aot", "IL3050", Justification = "The coordination payload type is internal, fixed, and controlled by this extension.")] - private async Task WaitForRunIdFileAsync(string runIdFilePath, int buildId, CancellationToken cancellationToken) + private async Task WaitForRunIdFileAsync(string runIdFilePath, string ownerFilePath, int buildId, CancellationToken cancellationToken) { - for (int attempt = 0; attempt < _options.CoordinationReadRetryCount; attempt++) + DateTimeOffset joinerDeadline = _clock.UtcNow + _options.CoordinationJoinerMaxWaitTime; + + for (int attempt = 0; ; attempt++) { if (_fileSystem.ExistFile(runIdFilePath)) { @@ -230,10 +249,31 @@ private static bool IsProcessAlive(int processId) } } + // After the base retry budget is exhausted, only keep waiting as long as the owner lease + // still looks active (or temporarily unreadable). A long CreateTestRunAsync can outlast + // CoordinationReadRetryCount * CoordinationReadRetryDelay, but we still want to bound the + // wait by CoordinationJoinerMaxWaitTime so a crashed owner doesn't stall joiners forever. + if (attempt >= _options.CoordinationReadRetryCount) + { + if (_clock.UtcNow >= joinerDeadline) + { + return null; + } + + LeaseReadResult ownerLease = ReadLease(ownerFilePath); + bool ownerStillActive = ownerLease.Status is LeaseFileStatus.Active or LeaseFileStatus.TransientReadError + || (ownerLease.Status == LeaseFileStatus.Expired + && ownerLease.Lease is { } expiredLease + && IsProcessAlive(expiredLease.ProcessId)); + + if (!ownerStillActive) + { + return null; + } + } + await _task.Delay(_options.CoordinationReadRetryDelay, cancellationToken).ConfigureAwait(false); } - - return null; } [UnconditionalSuppressMessage("ReflectionAnalysis", "IL2026", Justification = "The coordination payload type is internal, fixed, and controlled by this extension.")] @@ -259,8 +299,16 @@ private async Task TryAcquireOwnerAsync(string ownerFilePath, int buildId, return true; } - AzureDevOpsLeaseFile? existingLease = TryReadLeaseFile(ownerFilePath); - if (existingLease is null || existingLease.ExpiresAt <= _clock.UtcNow) + LeaseReadResult existing = ReadLease(ownerFilePath); + + // If the file exists but we couldn't read it (likely partial write from the current owner), + // refuse to take over — otherwise we'd race and create a duplicate Azure DevOps run. + if (existing.Status == LeaseFileStatus.TransientReadError) + { + return false; + } + + if (existing.Status is LeaseFileStatus.NotFound or LeaseFileStatus.Expired) { TryDeleteFile(ownerFilePath); return await TryWriteLeaseFileAsync(ownerFilePath, lease, overwrite: false, cancellationToken).ConfigureAwait(false); @@ -274,11 +322,11 @@ private AzureDevOpsLeaseFile CreateLeaseFile(int buildId) [UnconditionalSuppressMessage("ReflectionAnalysis", "IL2026", Justification = "The coordination payload type is internal, fixed, and controlled by this extension.")] [UnconditionalSuppressMessage("Aot", "IL3050", Justification = "The coordination payload type is internal, fixed, and controlled by this extension.")] - private AzureDevOpsLeaseFile? TryReadLeaseFile(string path) + private LeaseReadResult ReadLease(string path) { if (!_fileSystem.ExistFile(path)) { - return null; + return new LeaseReadResult(LeaseFileStatus.NotFound, null); } try @@ -287,13 +335,20 @@ private AzureDevOpsLeaseFile CreateLeaseFile(int buildId) AzureDevOpsLeaseFile? lease = JsonSerializer.Deserialize(content, JsonSerializerOptions); if (lease is not null) { - return lease; + return new LeaseReadResult( + lease.ExpiresAt > _clock.UtcNow ? LeaseFileStatus.Active : LeaseFileStatus.Expired, + lease); } if (int.TryParse(content, NumberStyles.Integer, CultureInfo.InvariantCulture, out int processId)) { - return new AzureDevOpsLeaseFile(processId, 0, DateTimeOffset.MinValue); + // Legacy plain-PID lease format: treat as expired so the caller can take over. + return new LeaseReadResult(LeaseFileStatus.Expired, new AzureDevOpsLeaseFile(processId, 0, DateTimeOffset.MinValue)); } + + // The file exists but neither parser yielded a usable value. It might be mid-write — + // surface this as a transient read error so the caller doesn't race the writer. + return new LeaseReadResult(LeaseFileStatus.TransientReadError, null); } catch (IOException) { @@ -305,7 +360,7 @@ private AzureDevOpsLeaseFile CreateLeaseFile(int buildId) { } - return null; + return new LeaseReadResult(LeaseFileStatus.TransientReadError, null); } [UnconditionalSuppressMessage("ReflectionAnalysis", "IL2026", Justification = "The coordination payload type is internal, fixed, and controlled by this extension.")] diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsTestResultsPublisher.cs b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsTestResultsPublisher.cs index 7229552a68..2d39c634a4 100644 --- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsTestResultsPublisher.cs +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsTestResultsPublisher.cs @@ -229,9 +229,18 @@ public async Task OnTestSessionFinishingAsync(ITestSessionContext testSessionCon _logger.LogWarning($"{AzureDevOpsResources.AzureDevOpsLivePublishingPublishResultsFailed} {ex.Message}"); } - string finalState = _testApplicationProcessExitCode.GetProcessExitCode() == 0 && !_testApplicationProcessExitCode.HasTestAdapterTestSessionFailure - ? AzureDevOpsLivePublishingConstants.CompletedTestRunState - : AzureDevOpsLivePublishingConstants.AbortedTestRunState; + // Azure DevOps test runs use "Aborted" specifically for cancellation or session-level + // infrastructure failures. Individual failing tests should still mark the run as + // "Completed" — only treat process exit codes other than Success/AtLeastOneTestFailed as + // an abort signal (e.g. TestSessionAborted, TestHostProcessExitedNonGracefully, + // TestAdapterTestSessionFailure, MinimumExpectedTestsPolicyViolation, etc.). + int exitCode = _testApplicationProcessExitCode.GetProcessExitCode(); + bool exitCodeIsTestResult = exitCode is (int)ExitCode.Success or (int)ExitCode.AtLeastOneTestFailed; + string finalState = testSessionContext.CancellationToken.IsCancellationRequested + || _testApplicationProcessExitCode.HasTestAdapterTestSessionFailure + || !exitCodeIsTestResult + ? AzureDevOpsLivePublishingConstants.AbortedTestRunState + : AzureDevOpsLivePublishingConstants.CompletedTestRunState; try { diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Microsoft.Testing.Extensions.AzureDevOpsReport.csproj b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Microsoft.Testing.Extensions.AzureDevOpsReport.csproj index 0c74785f45..c6b1c8339a 100644 --- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Microsoft.Testing.Extensions.AzureDevOpsReport.csproj +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Microsoft.Testing.Extensions.AzureDevOpsReport.csproj @@ -8,6 +8,7 @@ + diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/AzureDevOpsLivePublishingTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/AzureDevOpsLivePublishingTests.cs index a8f9197c23..12d222510d 100644 --- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/AzureDevOpsLivePublishingTests.cs +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/AzureDevOpsLivePublishingTests.cs @@ -132,6 +132,71 @@ public async Task OnTestSessionFinishingAsync_FlushesRemainingResults() Assert.AreEqual(AzureDevOpsLivePublishingConstants.CompletedTestRunState, client.UpdateTestRunStateCalls[0].State); } + [TestMethod] + public async Task OnTestSessionFinishingAsync_FailingTestsExitCode_FinalizesAsCompleted() + { + using TestDirectory directory = CreateTestDirectory(); + Mock processExitCode = new(); + processExitCode.Setup(x => x.GetProcessExitCode()).Returns(2); // ExitCode.AtLeastOneTestFailed + processExitCode.SetupGet(x => x.HasTestAdapterTestSessionFailure).Returns(false); + AzureDevOpsTestResultsPublisher publisher = CreatePublisher(directory.Path, options: new(10, TimeSpan.FromMinutes(1), 4, TimeSpan.FromMilliseconds(1)), out FakeAzureDevOpsTestResultsClient client, out _, out _, processExitCode: processExitCode); + client.CreateTestRunAsyncFunc = (_, _) => Task.FromResult(110); + + await StartPublisherAsync(publisher); + await publisher.OnTestSessionFinishingAsync(new Microsoft.Testing.Platform.Services.TestSessionContext(CancellationToken.None)); + + Assert.HasCount(1, client.UpdateTestRunStateCalls); + Assert.AreEqual(AzureDevOpsLivePublishingConstants.CompletedTestRunState, client.UpdateTestRunStateCalls[0].State); + } + + [TestMethod] + public async Task OnTestSessionFinishingAsync_SessionAbortedExitCode_FinalizesAsAborted() + { + using TestDirectory directory = CreateTestDirectory(); + Mock processExitCode = new(); + processExitCode.Setup(x => x.GetProcessExitCode()).Returns(3); // ExitCode.TestSessionAborted + processExitCode.SetupGet(x => x.HasTestAdapterTestSessionFailure).Returns(false); + AzureDevOpsTestResultsPublisher publisher = CreatePublisher(directory.Path, options: new(10, TimeSpan.FromMinutes(1), 4, TimeSpan.FromMilliseconds(1)), out FakeAzureDevOpsTestResultsClient client, out _, out _, processExitCode: processExitCode); + client.CreateTestRunAsyncFunc = (_, _) => Task.FromResult(111); + + await StartPublisherAsync(publisher); + await publisher.OnTestSessionFinishingAsync(new Microsoft.Testing.Platform.Services.TestSessionContext(CancellationToken.None)); + + Assert.HasCount(1, client.UpdateTestRunStateCalls); + Assert.AreEqual(AzureDevOpsLivePublishingConstants.AbortedTestRunState, client.UpdateTestRunStateCalls[0].State); + } + + [TestMethod] + public async Task OnTestSessionFinishingAsync_SessionCanceled_FinalizesAsAborted() + { + using TestDirectory directory = CreateTestDirectory(); + AzureDevOpsTestResultsPublisher publisher = CreatePublisher(directory.Path, options: new(10, TimeSpan.FromMinutes(1), 4, TimeSpan.FromMilliseconds(1)), out FakeAzureDevOpsTestResultsClient client, out _, out _); + client.CreateTestRunAsyncFunc = (_, _) => Task.FromResult(112); + + await StartPublisherAsync(publisher); + await publisher.OnTestSessionFinishingAsync(new Microsoft.Testing.Platform.Services.TestSessionContext(new CancellationToken(canceled: true))); + + Assert.HasCount(1, client.UpdateTestRunStateCalls); + Assert.AreEqual(AzureDevOpsLivePublishingConstants.AbortedTestRunState, client.UpdateTestRunStateCalls[0].State); + } + + [TestMethod] + public async Task OnTestSessionFinishingAsync_TestAdapterFailure_FinalizesAsAborted() + { + using TestDirectory directory = CreateTestDirectory(); + Mock processExitCode = new(); + processExitCode.Setup(x => x.GetProcessExitCode()).Returns(10); // ExitCode.TestAdapterTestSessionFailure + processExitCode.SetupGet(x => x.HasTestAdapterTestSessionFailure).Returns(true); + AzureDevOpsTestResultsPublisher publisher = CreatePublisher(directory.Path, options: new(10, TimeSpan.FromMinutes(1), 4, TimeSpan.FromMilliseconds(1)), out FakeAzureDevOpsTestResultsClient client, out _, out _, processExitCode: processExitCode); + client.CreateTestRunAsyncFunc = (_, _) => Task.FromResult(113); + + await StartPublisherAsync(publisher); + await publisher.OnTestSessionFinishingAsync(new Microsoft.Testing.Platform.Services.TestSessionContext(CancellationToken.None)); + + Assert.HasCount(1, client.UpdateTestRunStateCalls); + Assert.AreEqual(AzureDevOpsLivePublishingConstants.AbortedTestRunState, client.UpdateTestRunStateCalls[0].State); + } + [TestMethod] public void CreateTestCaseResult_MapsMtpStatesToAzdoResults() { @@ -366,6 +431,100 @@ await coordinator.FinalizeRunAsync(new AzureDevOpsCoordinatedRun(5, true, 123, d Assert.IsFalse(File.Exists(runIdFilePath)); } + [TestMethod] + public async Task RunIdCoordinator_AcquireRunAsync_TransientUnreadableOwnerPreservesItForRetry() + { + using TestDirectory directory = CreateTestDirectory(); + FakeClock clock = new() { UtcNow = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero) }; + CollectingLogger logger = new(); + // Tiny joiner max-wait so the test gives up quickly when the owner file looks transient. + AzureDevOpsTestResultsPublisherOptions options = new(10, TimeSpan.FromSeconds(5), 2, TimeSpan.FromMilliseconds(1), TimeSpan.FromSeconds(30), TimeSpan.FromHours(4), TimeSpan.FromMilliseconds(20)); + FakeTask task = new(timeSpan => clock.UtcNow += timeSpan); + Mock environment = CreateEnvironmentMock(processId: GetAliveProcessId()); + SystemFileSystem fileSystem = new(); + AzureDevOpsRunIdCoordinator coordinator = new(fileSystem, task, clock, environment.Object, logger, options); + AzureDevOpsPublishConfiguration configuration = new("https://dev.azure.com/org/", "project", "token", 123, "run", "tests.dll", directory.Path); + + // Write garbage that mimics a partial owner-write (file exists, but content is unparseable JSON). + string ownerFilePath = Path.Combine(directory.Path, "azdo-runid.123.owner"); + File.WriteAllText(ownerFilePath, "{partial"); + + // Acquiring should fail because the owner file looks transient and we refuse to clobber it. + InvalidOperationException exception = await Assert.ThrowsExactlyAsync( + () => coordinator.AcquireRunAsync(configuration, _ => Task.FromResult(99), CancellationToken.None)); + + Assert.AreEqual(AzureDevOpsResources.AzureDevOpsLivePublishingMissingRunIdFile, exception.Message); + // The owner file must still be present so the real owner can complete its write. + Assert.IsTrue(File.Exists(ownerFilePath)); + } + + [TestMethod] + public async Task RunIdCoordinator_AcquireRunAsync_JoinerKeepsWaitingWhileOwnerLeaseValid() + { + using TestDirectory directory = CreateTestDirectory(); + FakeClock clock = new() { UtcNow = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero) }; + CollectingLogger logger = new(); + // CoordinationReadRetryCount=2 — the initial retry budget is small (4 ms total). Without + // owner-lease-aware waiting the joiner would give up immediately, but the joiner max-wait + // (200 ms) plus an active owner lease (1 h) lets it keep polling. + AzureDevOpsTestResultsPublisherOptions options = new(10, TimeSpan.FromSeconds(5), 2, TimeSpan.FromMilliseconds(1), TimeSpan.FromSeconds(30), TimeSpan.FromHours(4), TimeSpan.FromMilliseconds(200)); + FakeTask task = new(timeSpan => clock.UtcNow += timeSpan); + int joinerProcessId = int.MaxValue; + Mock joinerEnvironment = CreateEnvironmentMock(processId: joinerProcessId); + SystemFileSystem fileSystem = new(); + AzureDevOpsRunIdCoordinator joinerCoordinator = new(fileSystem, task, clock, joinerEnvironment.Object, logger, options); + AzureDevOpsPublishConfiguration configuration = new("https://dev.azure.com/org/", "project", "token", 123, "run", "tests.dll", directory.Path); + + string ownerFilePath = Path.Combine(directory.Path, "azdo-runid.123.owner"); + string runIdFilePath = Path.Combine(directory.Path, "azdo-runid.123.json"); + + // Simulate an owner that has acquired the lease but is still inside a long CreateTestRunAsync. + File.WriteAllText(ownerFilePath, JsonSerializer.Serialize(new AzureDevOpsLeaseFile(GetAliveProcessId(), 123, clock.UtcNow.AddHours(1)))); + + // Drop the run-id file after the joiner has already exhausted the base retry budget so we + // exercise the owner-lease-aware extension of the wait loop. + int delayCalls = 0; + task = new(timeSpan => + { + clock.UtcNow += timeSpan; + delayCalls++; + if (delayCalls == 3) + { + File.WriteAllText(runIdFilePath, JsonSerializer.Serialize(new AzureDevOpsRunIdFile(77, 123, configuration.CollectionUri, configuration.Project, clock.UtcNow.AddHours(1)))); + } + }); + joinerCoordinator = new(fileSystem, task, clock, joinerEnvironment.Object, logger, options); + + AzureDevOpsCoordinatedRun coordinatedRun = await joinerCoordinator.AcquireRunAsync(configuration, _ => Task.FromResult(0), CancellationToken.None); + + Assert.AreEqual(77, coordinatedRun.RunId); + Assert.IsFalse(coordinatedRun.IsOwner); + Assert.IsGreaterThan(2, delayCalls); + } + + [TestMethod] + public async Task RunIdCoordinator_AcquireRunAsync_JoinerGivesUpWhenOwnerLeaseExpires() + { + using TestDirectory directory = CreateTestDirectory(); + FakeClock clock = new() { UtcNow = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero) }; + CollectingLogger logger = new(); + AzureDevOpsTestResultsPublisherOptions options = new(10, TimeSpan.FromSeconds(5), 2, TimeSpan.FromMilliseconds(1), TimeSpan.FromSeconds(30), TimeSpan.FromHours(4), TimeSpan.FromMinutes(5)); + FakeTask task = new(timeSpan => clock.UtcNow += timeSpan); + Mock joinerEnvironment = CreateEnvironmentMock(processId: int.MaxValue); + SystemFileSystem fileSystem = new(); + AzureDevOpsRunIdCoordinator joinerCoordinator = new(fileSystem, task, clock, joinerEnvironment.Object, logger, options); + AzureDevOpsPublishConfiguration configuration = new("https://dev.azure.com/org/", "project", "token", 123, "run", "tests.dll", directory.Path); + + // Owner lease is already expired and the owner PID is a long-dead one, so the joiner should + // take over and create the run itself rather than waiting indefinitely. + File.WriteAllText(Path.Combine(directory.Path, "azdo-runid.123.owner"), JsonSerializer.Serialize(new AzureDevOpsLeaseFile(int.MaxValue, 123, clock.UtcNow.AddMinutes(-5)))); + + AzureDevOpsCoordinatedRun coordinatedRun = await joinerCoordinator.AcquireRunAsync(configuration, _ => Task.FromResult(123), CancellationToken.None); + + Assert.AreEqual(123, coordinatedRun.RunId); + Assert.IsTrue(coordinatedRun.IsOwner); + } + private static async Task StartPublisherAsync(AzureDevOpsTestResultsPublisher publisher) { Assert.IsTrue(await publisher.IsEnabledAsync()); @@ -422,7 +581,8 @@ private static AzureDevOpsTestResultsPublisher CreatePublisher( out CollectingLogger logger, Mock? environment = null, Mock? testApplicationModuleInfo = null, - ITask? task = null) + ITask? task = null, + Mock? processExitCode = null) { Mock commandLineOptions = new(); commandLineOptions.Setup(x => x.IsOptionSet(AzureDevOpsCommandLineOptions.PublishAzureDevOpsTestResultsOptionName)).Returns(true); @@ -438,9 +598,12 @@ private static AzureDevOpsTestResultsPublisher CreatePublisher( testApplicationModuleInfo.Setup(x => x.TryGetAssemblyName()).Returns("MyTests"); testApplicationModuleInfo.Setup(x => x.GetCurrentTestApplicationFullPath()).Returns(Path.Combine("testfx-worktrees", "azdo-live", "artifacts", "MyTests.dll")); - Mock processExitCode = new(); - processExitCode.Setup(x => x.GetProcessExitCode()).Returns(0); - processExitCode.SetupGet(x => x.HasTestAdapterTestSessionFailure).Returns(false); + if (processExitCode is null) + { + processExitCode = new Mock(); + processExitCode.Setup(x => x.GetProcessExitCode()).Returns(0); + processExitCode.SetupGet(x => x.HasTestAdapterTestSessionFailure).Returns(false); + } client = new FakeAzureDevOpsTestResultsClient(); clock = new FakeClock { UtcNow = new DateTimeOffset(2025, 1, 1, 12, 0, 0, TimeSpan.Zero) };