From 88a782b66290008b773d42b1dc5e521c36e8d8da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Tue, 16 Jun 2026 10:12:54 +0200 Subject: [PATCH 1/3] Add Azure DevOps per-assembly log groups (#9141) Wrap 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 render in the modern AzDO Pipelines log viewer. Gated on --report-azdo + TF_BUILD=true, consistent with the rest of the extension. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AzureDevOpsExtensions.cs | 10 ++ .../AzureDevOpsLogGroupReporter.cs | 122 ++++++++++++++++++ .../Resources/AzureDevOpsResources.resx | 4 + .../Resources/xlf/AzureDevOpsResources.cs.xlf | 5 + .../Resources/xlf/AzureDevOpsResources.de.xlf | 5 + .../Resources/xlf/AzureDevOpsResources.es.xlf | 5 + .../Resources/xlf/AzureDevOpsResources.fr.xlf | 5 + .../Resources/xlf/AzureDevOpsResources.it.xlf | 5 + .../Resources/xlf/AzureDevOpsResources.ja.xlf | 5 + .../Resources/xlf/AzureDevOpsResources.ko.xlf | 5 + .../Resources/xlf/AzureDevOpsResources.pl.xlf | 5 + .../xlf/AzureDevOpsResources.pt-BR.xlf | 5 + .../Resources/xlf/AzureDevOpsResources.ru.xlf | 5 + .../Resources/xlf/AzureDevOpsResources.tr.xlf | 5 + .../xlf/AzureDevOpsResources.zh-Hans.xlf | 5 + .../xlf/AzureDevOpsResources.zh-Hant.xlf | 5 + .../AzureDevOpsLogGroupReporterTests.cs | 117 +++++++++++++++++ 17 files changed, 318 insertions(+) create mode 100644 src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsLogGroupReporter.cs create mode 100644 test/UnitTests/Microsoft.Testing.Extensions.UnitTests/AzureDevOpsLogGroupReporterTests.cs diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsExtensions.cs b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsExtensions.cs index 957af2ea6e..bf63798734 100644 --- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsExtensions.cs +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsExtensions.cs @@ -53,6 +53,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( @@ -88,6 +97,7 @@ public static void AddAzureDevOpsProvider(this ITestApplicationBuilder builder) builder.TestHost.AddTestSessionLifetimeHandler(compositeArtifactUploader); builder.TestHost.AddTestSessionLifetimeHandler(compositeSummaryReporter); builder.TestHost.AddTestSessionLifetimeHandler(compositeProgressReporter); + builder.TestHost.AddTestSessionLifetimeHandler(compositeLogGroupReporter); builder.TestHost.AddTestSessionLifetimeHandler(compositeTestResultsPublisher); 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..57d0212beb --- /dev/null +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsLogGroupReporter.cs @@ -0,0 +1,122 @@ +// 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.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. +/// +internal sealed class AzureDevOpsLogGroupReporter : ITestSessionLifetimeHandler, IOutputDeviceDataProducer +{ + private const string TfBuildVariableName = "TF_BUILD"; + + 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 Task IsEnabledAsync() + { + bool isEnabledByParameter = _commandLineOptions.IsOptionSet(AzureDevOpsCommandLineOptions.AzureDevOpsOptionName); + if (!isEnabledByParameter) + { + return Task.FromResult(false); + } + + bool isEnabledByEnvVariable = string.Equals(_environment.GetEnvironmentVariable(TfBuildVariableName), "true", StringComparison.OrdinalIgnoreCase); + return Task.FromResult(isEnabledByEnvVariable); + } + + 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 f3bfb551fd..b4eacae207 100644 --- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/AzureDevOpsResources.resx +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/Resources/AzureDevOpsResources.resx @@ -274,6 +274,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 f832efd2ef..dd73abaa7b 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 @@ -212,6 +212,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 96a8f8133c..ece4ef90fd 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 @@ -212,6 +212,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 ba81f8c7de..d5d1465d54 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 @@ -212,6 +212,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 c4c3d57aca..f309d3a5db 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 @@ -212,6 +212,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 0a2d527046..e2d41ce0b6 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 @@ -212,6 +212,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 988751e570..a5555aeba4 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 @@ -212,6 +212,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 bd1aab0001..19f9e60578 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 @@ -212,6 +212,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 70ac385e31..25482634b3 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 @@ -212,6 +212,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 56be46be0f..0445dfd0f5 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 @@ -212,6 +212,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 9685793f0d..9f1ae6c881 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 @@ -212,6 +212,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 46b3d788b3..450fe3dae4 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 @@ -212,6 +212,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 735f952429..75d1fa7a6c 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 @@ -212,6 +212,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 0040b0d8cb..4288db97b1 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 @@ -212,6 +212,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..653573d897 --- /dev/null +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/AzureDevOpsLogGroupReporterTests.cs @@ -0,0 +1,117 @@ +// 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.Reporting; +using Microsoft.Testing.Extensions.UnitTests.Helpers; +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); + Assert.StartsWith("##[group]Tests: 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()); + } + + private AzureDevOpsLogGroupReporter CreateReporter(bool enabled, bool tfBuild) + { + Dictionary options = enabled + ? new Dictionary { [AzureDevOpsCommandLineOptions.AzureDevOpsOptionName] = [] } + : []; + _ = _environmentMock.Setup(e => e.GetEnvironmentVariable("TF_BUILD")).Returns(tfBuild ? "true" : 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; + } +} From 865945beb061aa55406e5c7db1143fe21ccd77ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Tue, 16 Jun 2026 10:51:58 +0200 Subject: [PATCH 2/3] Address review: reuse AzureDevOpsConstants.IsRunningInAzureDevOps Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AzureDevOpsLogGroupReporter.cs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsLogGroupReporter.cs b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsLogGroupReporter.cs index 57d0212beb..4f029663dd 100644 --- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsLogGroupReporter.cs +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsLogGroupReporter.cs @@ -21,8 +21,6 @@ namespace Microsoft.Testing.Extensions.AzureDevOpsReport; /// internal sealed class AzureDevOpsLogGroupReporter : ITestSessionLifetimeHandler, IOutputDeviceDataProducer { - private const string TfBuildVariableName = "TF_BUILD"; - private readonly ICommandLineOptions _commandLineOptions; private readonly IEnvironment _environment; private readonly IOutputDevice _outputDevice; @@ -63,8 +61,7 @@ public Task IsEnabledAsync() return Task.FromResult(false); } - bool isEnabledByEnvVariable = string.Equals(_environment.GetEnvironmentVariable(TfBuildVariableName), "true", StringComparison.OrdinalIgnoreCase); - return Task.FromResult(isEnabledByEnvVariable); + return Task.FromResult(AzureDevOpsConstants.IsRunningInAzureDevOps(_environment)); } public async Task OnTestSessionStartingAsync(ITestSessionContext testSessionContext) From f2c558c8b0c3b4c3f516db3ad9483669fa7eface Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Tue, 16 Jun 2026 12:12:20 +0200 Subject: [PATCH 3/3] Address review: fix IDE0046 build break; make log group last-wrapping; harden tests - IsEnabledAsync is now an expression body (fixes IDE0046 error that broke the build). - AzureDevOpsLogGroupReporter now also implements IDataConsumer (no-op consume) and is registered last, so ##[endgroup] is emitted in the consumer phase after the other AzDO handlers' finishing ##vso lines, making the group wrap the whole assembly's output. - Tests derive the header prefix from AzureDevOpsResources.LogGroupHeader and use AzureDevOpsConstants for TF_BUILD; added a ConsumeAsync no-op test. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AzureDevOpsExtensions.cs | 6 +++- .../AzureDevOpsLogGroupReporter.cs | 32 +++++++++++------ .../AzureDevOpsLogGroupReporterTests.cs | 35 +++++++++++++++++-- 3 files changed, 60 insertions(+), 13 deletions(-) diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsExtensions.cs b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsExtensions.cs index 146f6229b6..afd1d62ccb 100644 --- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsExtensions.cs +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsExtensions.cs @@ -82,12 +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(compositeLogGroupReporter); 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 index 4f029663dd..8f6521b2f7 100644 --- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsLogGroupReporter.cs +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsLogGroupReporter.cs @@ -4,6 +4,8 @@ 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; @@ -19,7 +21,16 @@ namespace Microsoft.Testing.Extensions.AzureDevOpsReport; /// default. Unlike ##vso[task.logdetail] records, the ##[group] format commands are /// rendered by the modern Azure DevOps Pipelines log viewer. /// -internal sealed class AzureDevOpsLogGroupReporter : ITestSessionLifetimeHandler, IOutputDeviceDataProducer +/// +/// 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; @@ -53,16 +64,17 @@ public AzureDevOpsLogGroupReporter( public string Description => AzureDevOpsResources.Description; - public Task IsEnabledAsync() - { - bool isEnabledByParameter = _commandLineOptions.IsOptionSet(AzureDevOpsCommandLineOptions.AzureDevOpsOptionName); - if (!isEnabledByParameter) - { - return Task.FromResult(false); - } + public Type[] DataTypesConsumed { get; } = [typeof(TestNodeUpdateMessage)]; - return Task.FromResult(AzureDevOpsConstants.IsRunningInAzureDevOps(_environment)); - } + 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) { diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/AzureDevOpsLogGroupReporterTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/AzureDevOpsLogGroupReporterTests.cs index 653573d897..4e9d2826a3 100644 --- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/AzureDevOpsLogGroupReporterTests.cs +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/AzureDevOpsLogGroupReporterTests.cs @@ -2,8 +2,11 @@ // 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; @@ -64,7 +67,8 @@ public async Task SessionStarting_EmitsGroupHeaderWithAssemblyAndTfmAsync() string[] lines = GetFormattedLines(); Assert.HasCount(1, lines); - Assert.StartsWith("##[group]Tests: MyAssembly (", lines[0]); + string headerPrefix = AzureDevOpsResources.LogGroupHeader.Replace("{0}", string.Empty); + Assert.StartsWith($"##[group]{headerPrefix}MyAssembly (", lines[0]); } [TestMethod] @@ -91,12 +95,39 @@ public async Task SessionFinishing_DoesNothing_WhenGroupWasNeverOpenedAsync() 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("TF_BUILD")).Returns(tfBuild ? "true" : null); + _ = _environmentMock.Setup(e => e.GetEnvironmentVariable(AzureDevOpsConstants.TfBuildEnvironmentVariableName)) + .Returns(tfBuild ? AzureDevOpsConstants.TfBuildEnabledValue : null); return new AzureDevOpsLogGroupReporter( new TestCommandLineOptions(options), _environmentMock.Object,