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..b939985146 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsLivePublishingModels.cs @@ -0,0 +1,77 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Text.Json.Serialization; + +namespace Microsoft.Testing.Extensions.AzureDevOpsReport; + +internal static class AzureDevOpsLivePublishingConstants +{ + public const string AbortedTestOutcome = "Aborted"; + public const string AbortedTestRunState = "Aborted"; + public const string CompletedTestRunState = "Completed"; + public const string FailedTestOutcome = "Failed"; + public const string InProgressTestRunState = "InProgress"; + public const int MaxRunNameLength = 256; + public const string NotExecutedTestOutcome = "NotExecuted"; + public const string PassedTestOutcome = "Passed"; +} + +internal sealed record AzureDevOpsPublishConfiguration( + string CollectionUri, + string Project, + string AccessToken, + int BuildId, + string RunName, + string AutomatedTestStorage, + string ResultsDirectory); + +internal sealed record AzureDevOpsTestCaseResult( + [property: JsonPropertyName("automatedTestName")] string AutomatedTestName, + [property: JsonPropertyName("automatedTestStorage")] string AutomatedTestStorage, + [property: JsonPropertyName("testCaseTitle")] string TestCaseTitle, + [property: JsonPropertyName("outcome")] string Outcome, + [property: JsonPropertyName("durationInMs")] long? DurationInMs, + [property: JsonPropertyName("errorMessage")] string? ErrorMessage, + [property: JsonPropertyName("stackTrace")] string? StackTrace, + [property: JsonPropertyName("startedDate")] DateTimeOffset? StartedDate, + [property: JsonPropertyName("completedDate")] DateTimeOffset? CompletedDate); + +internal sealed record AzureDevOpsTestResultsPublisherOptions( + int BatchSize, + TimeSpan FlushInterval, + int CoordinationReadRetryCount, + TimeSpan CoordinationReadRetryDelay, + TimeSpan CoordinationFinalizeTimeout, + TimeSpan CoordinationFileExpiration, + TimeSpan CoordinationJoinerMaxWaitTime) +{ + public AzureDevOpsTestResultsPublisherOptions(int batchSize, TimeSpan flushInterval, int coordinationReadRetryCount, TimeSpan coordinationReadRetryDelay) + : this(batchSize, flushInterval, coordinationReadRetryCount, coordinationReadRetryDelay, TimeSpan.FromSeconds(30), TimeSpan.FromHours(4), TimeSpan.FromMinutes(2)) + { + } + + public AzureDevOpsTestResultsPublisherOptions(int batchSize, TimeSpan flushInterval, int coordinationReadRetryCount, TimeSpan coordinationReadRetryDelay, TimeSpan coordinationFinalizeTimeout, TimeSpan coordinationFileExpiration) + : this(batchSize, flushInterval, coordinationReadRetryCount, coordinationReadRetryDelay, coordinationFinalizeTimeout, coordinationFileExpiration, TimeSpan.FromMinutes(2)) + { + } + + public static AzureDevOpsTestResultsPublisherOptions Default { get; } = new(100, TimeSpan.FromSeconds(5), 40, TimeSpan.FromMilliseconds(250), TimeSpan.FromSeconds(30), TimeSpan.FromHours(4), TimeSpan.FromMinutes(2)); +} + +internal enum LeaseFileStatus +{ + /// 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 new file mode 100644 index 0000000000..79233269d1 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsRunIdCoordinator.cs @@ -0,0 +1,473 @@ +// 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, ownerFilePath, 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, ownerFilePath, 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); + if (ownsOwnerFile) + { + 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) + { + 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) + { + 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); + 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, string ownerFilePath, int buildId, CancellationToken cancellationToken) + { + DateTimeOffset joinerDeadline = _clock.UtcNow + _options.CoordinationJoinerMaxWaitTime; + + for (int attempt = 0; ; 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) + { + } + } + + // 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); + } + } + + [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; + } + + 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); + } + + 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 LeaseReadResult ReadLease(string path) + { + if (!_fileSystem.ExistFile(path)) + { + return new LeaseReadResult(LeaseFileStatus.NotFound, null); + } + + try + { + string content = _fileSystem.ReadAllText(path); + AzureDevOpsLeaseFile? lease = JsonSerializer.Deserialize(content, JsonSerializerOptions); + if (lease is not null) + { + return new LeaseReadResult( + lease.ExpiresAt > _clock.UtcNow ? LeaseFileStatus.Active : LeaseFileStatus.Expired, + lease); + } + + if (int.TryParse(content, NumberStyles.Integer, CultureInfo.InvariantCulture, out int processId)) + { + // 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) + { + } + catch (UnauthorizedAccessException) + { + } + catch (JsonException) + { + } + + return new LeaseReadResult(LeaseFileStatus.TransientReadError, 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..32b6e55a98 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsTestResultsClient.cs @@ -0,0 +1,286 @@ +// 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 > 0 + ? response.Id + : throw new InvalidOperationException(AzureDevOpsResources.AzureDevOpsLivePublishingInvalidResponse); + } + + 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; + + try + { + 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); + } + } + } + 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); + } + + 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..2d39c634a4 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsTestResultsPublisher.cs @@ -0,0 +1,501 @@ +// 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 CancellationTokenSource? _backgroundFlushCts; + private Task? _backgroundFlushTask; + + 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() + { + // 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(); + } + + 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; + + // 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) + { + _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; + } + + // 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 (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 + { + // Cancellation — expected and fine. + } + } + + try + { + await _runIdCoordinator.RenewLeaseAsync(_coordinatedRun, testSessionContext.CancellationToken).ConfigureAwait(false); + await FlushPendingResultsAsync(force: true, testSessionContext.CancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Best-effort flush: session was canceled; finalization still runs below with a fresh token. + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogWarning($"{AzureDevOpsResources.AzureDevOpsLivePublishingPublishResultsFailed} {ex.Message}"); + } + + // 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 + { + // 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), + cleanupCts.Token).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 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) + { + 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..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 @@ + @@ -51,6 +52,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..d3b641204f 100644 --- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/AzureDevOpsResources.resx +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/AzureDevOpsResources.resx @@ -119,9 +119,10 @@ '--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. + Azure DevOps report generator to write errors to the output in a way that Azure DevOps understands. Azure DevOps report generator @@ -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 Azure DevOps 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..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 @@ -2,14 +2,79 @@ + + 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. - 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ý. @@ -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 Azure DevOps 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..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 @@ -2,14 +2,79 @@ + + 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. - 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. @@ -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 Azure DevOps 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..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 @@ -2,14 +2,79 @@ + + 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. - 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. @@ -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 Azure DevOps 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..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 @@ -2,14 +2,79 @@ + + 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. - 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. @@ -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 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. + + + + 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..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 @@ -2,14 +2,79 @@ + + 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. - 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. @@ -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 Azure DevOps 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..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 @@ -2,14 +2,79 @@ + + 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. - 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 レポート生成プログラム。 @@ -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 Azure DevOps 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..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 @@ -2,14 +2,79 @@ + + 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. - 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 보고서 생성기입니다. @@ -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 Azure DevOps 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..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 @@ -2,14 +2,79 @@ + + 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. - 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. @@ -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 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. + + + + 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..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 @@ -2,14 +2,79 @@ + + 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. - 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. @@ -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 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. + + + + 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..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 @@ -2,14 +2,79 @@ + + 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. - Генератор отчетов 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. @@ -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 Azure DevOps 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..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 @@ -2,14 +2,79 @@ + + 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. - 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. @@ -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 Azure DevOps 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..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 @@ -2,14 +2,79 @@ + + 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. - 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 可理解的方式将错误写入输出。 @@ -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 Azure DevOps 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..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 @@ -2,14 +2,79 @@ + + 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. - 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 可理解的方式將錯誤寫入輸出。 @@ -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 Azure DevOps 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 d989315655..6fb95254fa 100644 --- a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/HelpInfoAllExtensionsTests.cs +++ b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/HelpInfoAllExtensionsTests.cs @@ -103,6 +103,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 Azure DevOps 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 @@ -354,6 +362,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 Azure DevOps 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 Azure DevOps 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..12d222510d --- /dev/null +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/AzureDevOpsLivePublishingTests.cs @@ -0,0 +1,740 @@ +// 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 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() + { + 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)); + } + + [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()); + 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? processExitCode = 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(Path.Combine("testfx-worktrees", "azdo-live", "artifacts", "MyTests.dll")); + + 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) }; + 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); + } +}