diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsExtensions.cs b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsExtensions.cs index 8276944bb0..afd1d62ccb 100644 --- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsExtensions.cs +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsExtensions.cs @@ -44,6 +44,15 @@ public static void AddAzureDevOpsProvider(this ITestApplicationBuilder builder) serviceProvider.GetTestApplicationModuleInfo(), serviceProvider.GetLoggerFactory())); + var compositeLogGroupReporter = + new CompositeExtensionFactory(serviceProvider => + new AzureDevOpsLogGroupReporter( + serviceProvider.GetCommandLineOptions(), + serviceProvider.GetEnvironment(), + serviceProvider.GetOutputDevice(), + serviceProvider.GetTestApplicationModuleInfo(), + serviceProvider.GetLoggerFactory())); + var compositeTestResultsPublisher = new CompositeExtensionFactory(serviceProvider => new AzureDevOpsTestResultsPublisher( @@ -73,11 +82,16 @@ public static void AddAzureDevOpsProvider(this ITestApplicationBuilder builder) builder.TestHost.AddDataConsumer(compositeArtifactUploader); builder.TestHost.AddDataConsumer(compositeSummaryReporter); builder.TestHost.AddDataConsumer(compositeTestResultsPublisher); + builder.TestHost.AddDataConsumer(compositeLogGroupReporter); builder.TestHost.AddTestSessionLifetimeHandler(serviceProvider => historyService ??= CreateHistoryService(serviceProvider)); builder.TestHost.AddTestSessionLifetimeHandler(compositeArtifactUploader); builder.TestHost.AddTestSessionLifetimeHandler(compositeSummaryReporter); builder.TestHost.AddTestSessionLifetimeHandler(compositeTestResultsPublisher); + + // Registered last so its OnTestSessionFinishingAsync (the closing ##[endgroup]) runs after + // the other AzDO handlers' finishing callbacks, ensuring the group wraps all their output. + builder.TestHost.AddTestSessionLifetimeHandler(compositeLogGroupReporter); builder.CommandLine.AddProvider(() => new AzureDevOpsCommandLineProvider()); } diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsLogGroupReporter.cs b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsLogGroupReporter.cs new file mode 100644 index 0000000000..8f6521b2f7 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsLogGroupReporter.cs @@ -0,0 +1,131 @@ +// 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.CommandLine; +using Microsoft.Testing.Platform.Extensions; +using Microsoft.Testing.Platform.Extensions.Messages; +using Microsoft.Testing.Platform.Extensions.OutputDevice; +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; + +/// +/// Wraps each test assembly's output in an Azure DevOps log group (##[group] / +/// ##[endgroup]) so the raw pipeline log view collapses the assembly's test output by +/// default. Unlike ##vso[task.logdetail] records, the ##[group] format commands are +/// rendered by the modern Azure DevOps Pipelines log viewer. +/// +/// +/// This handler also implements (with a no-op +/// ) purely so that, at session +/// end, CommonTestHost.NotifyTestSessionEndAsync runs its +/// in the consumer phase — i.e. +/// after the producer-only AzDO handlers. Combined with registering it last, this ensures the +/// closing ##[endgroup] is emitted after the other reporters' final ##vso[...] lines, +/// so the group truly wraps the whole assembly's output. +/// +internal sealed class AzureDevOpsLogGroupReporter : IDataConsumer, ITestSessionLifetimeHandler, IOutputDeviceDataProducer +{ + private readonly ICommandLineOptions _commandLineOptions; + private readonly IEnvironment _environment; + private readonly IOutputDevice _outputDevice; + private readonly ITestApplicationModuleInfo _testApplicationModuleInfo; + private readonly ILogger _logger; + private readonly Lazy _targetFrameworkMoniker; + + private bool _groupOpened; + + public AzureDevOpsLogGroupReporter( + ICommandLineOptions commandLineOptions, + IEnvironment environment, + IOutputDevice outputDevice, + ITestApplicationModuleInfo testApplicationModuleInfo, + ILoggerFactory loggerFactory) + { + _commandLineOptions = commandLineOptions; + _environment = environment; + _outputDevice = outputDevice; + _testApplicationModuleInfo = testApplicationModuleInfo; + _logger = loggerFactory.CreateLogger(); + _targetFrameworkMoniker = new(TargetFrameworkMonikerHelper.GetTargetFrameworkMoniker); + } + + public string Uid => nameof(AzureDevOpsLogGroupReporter); + + public string Version => ExtensionVersion.DefaultSemVer; + + public string DisplayName => AzureDevOpsResources.DisplayName; + + public string Description => AzureDevOpsResources.Description; + + public Type[] DataTypesConsumed { get; } = [typeof(TestNodeUpdateMessage)]; + + public Task IsEnabledAsync() + => Task.FromResult( + _commandLineOptions.IsOptionSet(AzureDevOpsCommandLineOptions.AzureDevOpsOptionName) + && AzureDevOpsConstants.IsRunningInAzureDevOps(_environment)); + + // No-op: this consumer subscribes to data only to be ordered in the consumer phase at session + // end (see the type-level remarks). It does not act on individual messages. + public Task ConsumeAsync(IDataProducer dataProducer, IData value, CancellationToken cancellationToken) + => Task.CompletedTask; + + public async Task OnTestSessionStartingAsync(ITestSessionContext testSessionContext) + { + try + { + testSessionContext.CancellationToken.ThrowIfCancellationRequested(); + + string name = $"{_testApplicationModuleInfo.TryGetAssemblyName() ?? "unknown"} ({_targetFrameworkMoniker.Value})"; + string line = $"##[group]{AzDoEscaper.Escape(string.Format(CultureInfo.InvariantCulture, AzureDevOpsResources.LogGroupHeader, name))}"; + await _outputDevice.DisplayAsync(this, new FormattedTextOutputDeviceData(line), testSessionContext.CancellationToken).ConfigureAwait(false); + _groupOpened = true; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + LogUnexpectedException(nameof(OnTestSessionStartingAsync), ex); + } + } + + public async Task OnTestSessionFinishingAsync(ITestSessionContext testSessionContext) + { + try + { + testSessionContext.CancellationToken.ThrowIfCancellationRequested(); + + if (!_groupOpened) + { + return; + } + + await _outputDevice.DisplayAsync(this, new FormattedTextOutputDeviceData("##[endgroup]"), testSessionContext.CancellationToken).ConfigureAwait(false); + _groupOpened = false; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + LogUnexpectedException(nameof(OnTestSessionFinishingAsync), ex); + } + } + + private void LogUnexpectedException(string callbackName, Exception ex) + { + if (_logger.IsEnabled(LogLevel.Warning)) + { + _logger.LogWarning($"Unexpected exception in {callbackName}: {ex}"); + } + } +} diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/AzureDevOpsResources.resx b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/AzureDevOpsResources.resx index 0f774cfab3..d3c20a64ce 100644 --- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/AzureDevOpsResources.resx +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/AzureDevOpsResources.resx @@ -270,6 +270,10 @@ Invalid '--report-azdo-stackframe-filter' regex '{0}': {1} {0} is the regex pattern. {1} is the validation error message. {Locked="--report-azdo-stackframe-filter"} + + Tests: {0} + {0} is the test assembly name and target framework, e.g. 'MSTest.UnitTests (net9.0)'. This text is the collapsible Azure DevOps log group header. + Test failed without an additional message. 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 65c13874e3..6620341d6a 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 @@ -207,6 +207,11 @@ Neplatný regulární výraz --report-azdo-stackframe-filter {0}: {1} {0} is the regex pattern. {1} is the validation error message. {Locked="--report-azdo-stackframe-filter"} + + Tests: {0} + Tests: {0} + {0} is the test assembly name and target framework, e.g. 'MSTest.UnitTests (net9.0)'. This text is the collapsible Azure DevOps log group header. + Test failed without an additional message. Test neproběhl úspěšně bez další zprávy. 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 49362818aa..47e25b8e5c 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 @@ -207,6 +207,11 @@ Ungültiger „--report-azdo-stackframe-filter“ RegEx „{0}“: {1} {0} is the regex pattern. {1} is the validation error message. {Locked="--report-azdo-stackframe-filter"} + + Tests: {0} + Tests: {0} + {0} is the test assembly name and target framework, e.g. 'MSTest.UnitTests (net9.0)'. This text is the collapsible Azure DevOps log group header. + Test failed without an additional message. Fehler beim Test ohne zusätzliche Meldung. 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 691f8b9733..ce0f94dd73 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 @@ -207,6 +207,11 @@ Expresión regular no válida de "--report-azdo-stackframe-filter" "{0}": {1} {0} is the regex pattern. {1} is the validation error message. {Locked="--report-azdo-stackframe-filter"} + + Tests: {0} + Tests: {0} + {0} is the test assembly name and target framework, e.g. 'MSTest.UnitTests (net9.0)'. This text is the collapsible Azure DevOps log group header. + Test failed without an additional message. Error en la prueba sin un mensaje adicional. 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 df1ca42d1a..d853bceffd 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 @@ -207,6 +207,11 @@ Expression régulière « {0} » non valide pour « --report-azdo-stackframe-filter » : {1} {0} is the regex pattern. {1} is the validation error message. {Locked="--report-azdo-stackframe-filter"} + + Tests: {0} + Tests: {0} + {0} is the test assembly name and target framework, e.g. 'MSTest.UnitTests (net9.0)'. This text is the collapsible Azure DevOps log group header. + Test failed without an additional message. Le test a échoué sans message supplémentaire. 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 433c4c749e..f33258fcf0 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 @@ -207,6 +207,11 @@ Espressione regolare non valida per "--report-azdo-stackframe-filter" "{0}": {1} {0} is the regex pattern. {1} is the validation error message. {Locked="--report-azdo-stackframe-filter"} + + Tests: {0} + Tests: {0} + {0} is the test assembly name and target framework, e.g. 'MSTest.UnitTests (net9.0)'. This text is the collapsible Azure DevOps log group header. + Test failed without an additional message. Test non riuscito senza un messaggio aggiuntivo. 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 2b24e2c20a..4906a1ef6a 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 @@ -207,6 +207,11 @@ 無効 '--report-azdo-stackframe-filter' regex '{0}': {1} {0} is the regex pattern. {1} is the validation error message. {Locked="--report-azdo-stackframe-filter"} + + Tests: {0} + Tests: {0} + {0} is the test assembly name and target framework, e.g. 'MSTest.UnitTests (net9.0)'. This text is the collapsible Azure DevOps log group header. + Test failed without an additional message. 追加のメッセージなしでテストに失敗しました。 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 bd8d251afd..47ee030429 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 @@ -207,6 +207,11 @@ 잘못된 '--report-azdo-stackframe-filter' 정규식 '{0}': {1} {0} is the regex pattern. {1} is the validation error message. {Locked="--report-azdo-stackframe-filter"} + + Tests: {0} + Tests: {0} + {0} is the test assembly name and target framework, e.g. 'MSTest.UnitTests (net9.0)'. This text is the collapsible Azure DevOps log group header. + Test failed without an additional message. 추가 메시지 없이 테스트가 실패했습니다. 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 6634b89911..081222c1b6 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 @@ -207,6 +207,11 @@ Nieprawidłowy wyrażenie regularne „--report-azdo-stackframe-filter” „{0}”: {1} {0} is the regex pattern. {1} is the validation error message. {Locked="--report-azdo-stackframe-filter"} + + Tests: {0} + Tests: {0} + {0} is the test assembly name and target framework, e.g. 'MSTest.UnitTests (net9.0)'. This text is the collapsible Azure DevOps log group header. + Test failed without an additional message. Test nie powiódł się bez dodatkowego komunikatu. 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 e7e8685919..aaf4310fee 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 @@ -207,6 +207,11 @@ Regex '--report-azdo-stackframe-filter' inválida '{0}': {1} {0} is the regex pattern. {1} is the validation error message. {Locked="--report-azdo-stackframe-filter"} + + Tests: {0} + Tests: {0} + {0} is the test assembly name and target framework, e.g. 'MSTest.UnitTests (net9.0)'. This text is the collapsible Azure DevOps log group header. + Test failed without an additional message. O teste falhou sem uma mensagem adicional. 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 0e8288a7d5..2891a49255 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 @@ -207,6 +207,11 @@ Недопустимое регулярное выражение "--report-azdo-stackframe-filter" "{0}": {1} {0} is the regex pattern. {1} is the validation error message. {Locked="--report-azdo-stackframe-filter"} + + Tests: {0} + Tests: {0} + {0} is the test assembly name and target framework, e.g. 'MSTest.UnitTests (net9.0)'. This text is the collapsible Azure DevOps log group header. + Test failed without an additional message. Тест завершился сбоем без дополнительного сообщения. 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 bf2eb2d4f2..bbbfe62932 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 @@ -207,6 +207,11 @@ Geçersiz '--report-azdo-stackframe-filter' normal ifade '{0}': {1} {0} is the regex pattern. {1} is the validation error message. {Locked="--report-azdo-stackframe-filter"} + + Tests: {0} + Tests: {0} + {0} is the test assembly name and target framework, e.g. 'MSTest.UnitTests (net9.0)'. This text is the collapsible Azure DevOps log group header. + Test failed without an additional message. Ek bir ileti olmadan test başarısız oldu. 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 84289d4d69..54101a00e0 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 @@ -207,6 +207,11 @@ '--report-azdo-stackframe-filter' 正则表达式 '{0}' 无效: {1} {0} is the regex pattern. {1} is the validation error message. {Locked="--report-azdo-stackframe-filter"} + + Tests: {0} + Tests: {0} + {0} is the test assembly name and target framework, e.g. 'MSTest.UnitTests (net9.0)'. This text is the collapsible Azure DevOps log group header. + Test failed without an additional message. 测试失败,无附加消息。 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 10d07f8c3f..fbcec24885 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 @@ -207,6 +207,11 @@ 無效的 '--report-azdo-stackframe-filter' regex '{0}': {1} {0} is the regex pattern. {1} is the validation error message. {Locked="--report-azdo-stackframe-filter"} + + Tests: {0} + Tests: {0} + {0} is the test assembly name and target framework, e.g. 'MSTest.UnitTests (net9.0)'. This text is the collapsible Azure DevOps log group header. + Test failed without an additional message. 測試失敗,沒有其他訊息。 diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/AzureDevOpsLogGroupReporterTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/AzureDevOpsLogGroupReporterTests.cs new file mode 100644 index 0000000000..4e9d2826a3 --- /dev/null +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/AzureDevOpsLogGroupReporterTests.cs @@ -0,0 +1,148 @@ +// 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; +using Microsoft.Testing.Extensions.AzureDevOpsReport.Resources; +using Microsoft.Testing.Extensions.Reporting; +using Microsoft.Testing.Extensions.UnitTests.Helpers; +using Microsoft.Testing.Platform.Extensions; +using Microsoft.Testing.Platform.Extensions.Messages; +using Microsoft.Testing.Platform.Extensions.OutputDevice; +using Microsoft.Testing.Platform.Helpers; +using Microsoft.Testing.Platform.Logging; +using Microsoft.Testing.Platform.OutputDevice; +using Microsoft.Testing.Platform.Services; +using Microsoft.Testing.Platform.TestHost; + +using Moq; + +namespace Microsoft.Testing.Extensions.UnitTests; + +[TestClass] +public sealed class AzureDevOpsLogGroupReporterTests +{ + private readonly Mock _environmentMock = new(); + private readonly Mock _outputDeviceMock = new(); + private readonly Mock _testApplicationModuleInfoMock = new(); + private readonly Mock _loggerFactoryMock = new(); + private readonly List _outputData = []; + + public AzureDevOpsLogGroupReporterTests() + { + _ = _testApplicationModuleInfoMock.Setup(info => info.TryGetAssemblyName()).Returns("MyAssembly"); + _ = _loggerFactoryMock.Setup(loggerFactory => loggerFactory.CreateLogger(It.IsAny())).Returns(Mock.Of()); + _ = _outputDeviceMock + .Setup(outputDevice => outputDevice.DisplayAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((_, data, _) => _outputData.Add(data)) + .Returns(Task.CompletedTask); + } + + [TestMethod] + public async Task IsEnabledAsync_ReturnsFalse_WhenAzureDevOpsOptionNotSetAsync() + { + AzureDevOpsLogGroupReporter reporter = CreateReporter(enabled: false, tfBuild: true); + Assert.IsFalse(await reporter.IsEnabledAsync()); + } + + [TestMethod] + public async Task IsEnabledAsync_ReturnsFalse_WhenTfBuildNotSetAsync() + { + AzureDevOpsLogGroupReporter reporter = CreateReporter(enabled: true, tfBuild: false); + Assert.IsFalse(await reporter.IsEnabledAsync()); + } + + [TestMethod] + public async Task IsEnabledAsync_ReturnsTrue_WhenAzureDevOpsOptionSetAndTfBuildSetAsync() + { + AzureDevOpsLogGroupReporter reporter = CreateReporter(enabled: true, tfBuild: true); + Assert.IsTrue(await reporter.IsEnabledAsync()); + } + + [TestMethod] + public async Task SessionStarting_EmitsGroupHeaderWithAssemblyAndTfmAsync() + { + AzureDevOpsLogGroupReporter reporter = CreateReporter(enabled: true, tfBuild: true); + + await reporter.OnTestSessionStartingAsync(new TestSessionContext()); + + string[] lines = GetFormattedLines(); + Assert.HasCount(1, lines); + string headerPrefix = AzureDevOpsResources.LogGroupHeader.Replace("{0}", string.Empty); + Assert.StartsWith($"##[group]{headerPrefix}MyAssembly (", lines[0]); + } + + [TestMethod] + public async Task SessionFinishing_EmitsEndGroup_WhenGroupWasOpenedAsync() + { + AzureDevOpsLogGroupReporter reporter = CreateReporter(enabled: true, tfBuild: true); + + await reporter.OnTestSessionStartingAsync(new TestSessionContext()); + await reporter.OnTestSessionFinishingAsync(new TestSessionContext()); + + string[] lines = GetFormattedLines(); + Assert.HasCount(2, lines); + Assert.StartsWith("##[group]", lines[0]); + Assert.AreEqual("##[endgroup]", lines[1]); + } + + [TestMethod] + public async Task SessionFinishing_DoesNothing_WhenGroupWasNeverOpenedAsync() + { + AzureDevOpsLogGroupReporter reporter = CreateReporter(enabled: true, tfBuild: true); + + await reporter.OnTestSessionFinishingAsync(new TestSessionContext()); + + Assert.IsEmpty(GetFormattedLines()); + } + + [TestMethod] + public async Task ConsumeAsync_IsNoOp_AndEmitsNothingAsync() + { + // The handler subscribes as an IDataConsumer only to be ordered in the consumer phase at + // session end; ConsumeAsync itself must not emit anything. + AzureDevOpsLogGroupReporter reporter = CreateReporter(enabled: true, tfBuild: true); + + Assert.Contains(typeof(TestNodeUpdateMessage), reporter.DataTypesConsumed); + await reporter.ConsumeAsync( + Mock.Of(), + CreateTestNodeUpdateMessage("t1", new PassedTestNodeStateProperty()), + CancellationToken.None); + + Assert.IsEmpty(GetFormattedLines()); + } + + private static TestNodeUpdateMessage CreateTestNodeUpdateMessage(string uid, TestNodeStateProperty state) + => new( + new SessionUid("session"), + new TestNode + { + Uid = uid, + DisplayName = uid, + Properties = new PropertyBag(state), + }); + + private AzureDevOpsLogGroupReporter CreateReporter(bool enabled, bool tfBuild) + { + Dictionary options = enabled + ? new Dictionary { [AzureDevOpsCommandLineOptions.AzureDevOpsOptionName] = [] } + : []; + _ = _environmentMock.Setup(e => e.GetEnvironmentVariable(AzureDevOpsConstants.TfBuildEnvironmentVariableName)) + .Returns(tfBuild ? AzureDevOpsConstants.TfBuildEnabledValue : null); + return new AzureDevOpsLogGroupReporter( + new TestCommandLineOptions(options), + _environmentMock.Object, + _outputDeviceMock.Object, + _testApplicationModuleInfoMock.Object, + _loggerFactoryMock.Object); + } + + private string[] GetFormattedLines() + => [.. _outputData.OfType().Select(output => output.Text)]; + + private sealed class TestSessionContext : ITestSessionContext + { + public SessionUid SessionUid { get; } = new("session"); + + public CancellationToken CancellationToken { get; } = CancellationToken.None; + } +}