Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,15 @@ public static void AddAzureDevOpsProvider(this ITestApplicationBuilder builder)
serviceProvider.GetTestApplicationModuleInfo(),
serviceProvider.GetLoggerFactory()));

var compositeLogGroupReporter =
new CompositeExtensionFactory<AzureDevOpsLogGroupReporter>(serviceProvider =>
new AzureDevOpsLogGroupReporter(
serviceProvider.GetCommandLineOptions(),
serviceProvider.GetEnvironment(),
serviceProvider.GetOutputDevice(),
serviceProvider.GetTestApplicationModuleInfo(),
serviceProvider.GetLoggerFactory()));

var compositeTestResultsPublisher =
new CompositeExtensionFactory<AzureDevOpsTestResultsPublisher>(serviceProvider =>
new AzureDevOpsTestResultsPublisher(
Expand Down Expand Up @@ -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);
Comment thread
Evangelink marked this conversation as resolved.

// 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());
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Wraps each test assembly's output in an Azure DevOps log group (<c>##[group]</c> /
/// <c>##[endgroup]</c>) so the raw pipeline log view collapses the assembly's test output by
/// default. Unlike <c>##vso[task.logdetail]</c> records, the <c>##[group]</c> format commands are
/// rendered by the modern Azure DevOps Pipelines log viewer.
/// </summary>
/// <remarks>
/// This handler also implements <see cref="IDataConsumer"/> (with a no-op
/// <see cref="ConsumeAsync(IDataProducer, IData, CancellationToken)"/>) purely so that, at session
/// end, <c>CommonTestHost.NotifyTestSessionEndAsync</c> runs its
/// <see cref="OnTestSessionFinishingAsync(ITestSessionContext)"/> in the consumer phase — i.e.
/// after the producer-only AzDO handlers. Combined with registering it last, this ensures the
/// closing <c>##[endgroup]</c> is emitted after the other reporters' final <c>##vso[...]</c> lines,
/// so the group truly wraps the whole assembly's output.
/// </remarks>
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<string> _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<AzureDevOpsLogGroupReporter>();
_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<bool> 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}");
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,10 @@
<value>Invalid '--report-azdo-stackframe-filter' regex '{0}': {1}</value>
<comment>{0} is the regex pattern. {1} is the validation error message. {Locked="--report-azdo-stackframe-filter"}</comment>
</data>
<data name="LogGroupHeader" xml:space="preserve">
<value>Tests: {0}</value>
<comment>{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.</comment>
</data>
<data name="NoFailureMessageFallback" xml:space="preserve">
<value>Test failed without an additional message.</value>
</data>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,11 @@
<target state="translated">Neplatný regulární výraz --report-azdo-stackframe-filter {0}: {1}</target>
<note>{0} is the regex pattern. {1} is the validation error message. {Locked="--report-azdo-stackframe-filter"}</note>
</trans-unit>
<trans-unit id="LogGroupHeader">
<source>Tests: {0}</source>
<target state="new">Tests: {0}</target>
<note>{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.</note>
</trans-unit>
<trans-unit id="NoFailureMessageFallback">
<source>Test failed without an additional message.</source>
<target state="translated">Test neproběhl úspěšně bez další zprávy.</target>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,11 @@
<target state="translated">Ungültiger „--report-azdo-stackframe-filter“ RegEx „{0}“: {1}</target>
<note>{0} is the regex pattern. {1} is the validation error message. {Locked="--report-azdo-stackframe-filter"}</note>
</trans-unit>
<trans-unit id="LogGroupHeader">
<source>Tests: {0}</source>
<target state="new">Tests: {0}</target>
<note>{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.</note>
</trans-unit>
<trans-unit id="NoFailureMessageFallback">
<source>Test failed without an additional message.</source>
<target state="translated">Fehler beim Test ohne zusätzliche Meldung.</target>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,11 @@
<target state="translated">Expresión regular no válida de "--report-azdo-stackframe-filter" "{0}": {1}</target>
<note>{0} is the regex pattern. {1} is the validation error message. {Locked="--report-azdo-stackframe-filter"}</note>
</trans-unit>
<trans-unit id="LogGroupHeader">
<source>Tests: {0}</source>
<target state="new">Tests: {0}</target>
<note>{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.</note>
</trans-unit>
<trans-unit id="NoFailureMessageFallback">
<source>Test failed without an additional message.</source>
<target state="translated">Error en la prueba sin un mensaje adicional.</target>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,11 @@
<target state="translated">Expression régulière « {0} » non valide pour « --report-azdo-stackframe-filter » : {1}</target>
<note>{0} is the regex pattern. {1} is the validation error message. {Locked="--report-azdo-stackframe-filter"}</note>
</trans-unit>
<trans-unit id="LogGroupHeader">
<source>Tests: {0}</source>
<target state="new">Tests: {0}</target>
<note>{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.</note>
</trans-unit>
<trans-unit id="NoFailureMessageFallback">
<source>Test failed without an additional message.</source>
<target state="translated">Le test a échoué sans message supplémentaire.</target>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,11 @@
<target state="translated">Espressione regolare non valida per "--report-azdo-stackframe-filter" "{0}": {1}</target>
<note>{0} is the regex pattern. {1} is the validation error message. {Locked="--report-azdo-stackframe-filter"}</note>
</trans-unit>
<trans-unit id="LogGroupHeader">
<source>Tests: {0}</source>
<target state="new">Tests: {0}</target>
<note>{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.</note>
</trans-unit>
<trans-unit id="NoFailureMessageFallback">
<source>Test failed without an additional message.</source>
<target state="translated">Test non riuscito senza un messaggio aggiuntivo.</target>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,11 @@
<target state="translated">無効 '--report-azdo-stackframe-filter' regex '{0}': {1}</target>
<note>{0} is the regex pattern. {1} is the validation error message. {Locked="--report-azdo-stackframe-filter"}</note>
</trans-unit>
<trans-unit id="LogGroupHeader">
<source>Tests: {0}</source>
<target state="new">Tests: {0}</target>
<note>{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.</note>
</trans-unit>
<trans-unit id="NoFailureMessageFallback">
<source>Test failed without an additional message.</source>
<target state="translated">追加のメッセージなしでテストに失敗しました。</target>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,11 @@
<target state="translated">잘못된 '--report-azdo-stackframe-filter' 정규식 '{0}': {1}</target>
<note>{0} is the regex pattern. {1} is the validation error message. {Locked="--report-azdo-stackframe-filter"}</note>
</trans-unit>
<trans-unit id="LogGroupHeader">
<source>Tests: {0}</source>
<target state="new">Tests: {0}</target>
<note>{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.</note>
</trans-unit>
<trans-unit id="NoFailureMessageFallback">
<source>Test failed without an additional message.</source>
<target state="translated">추가 메시지 없이 테스트가 실패했습니다.</target>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,11 @@
<target state="translated">Nieprawidłowy wyrażenie regularne „--report-azdo-stackframe-filter” „{0}”: {1}</target>
<note>{0} is the regex pattern. {1} is the validation error message. {Locked="--report-azdo-stackframe-filter"}</note>
</trans-unit>
<trans-unit id="LogGroupHeader">
<source>Tests: {0}</source>
<target state="new">Tests: {0}</target>
<note>{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.</note>
</trans-unit>
<trans-unit id="NoFailureMessageFallback">
<source>Test failed without an additional message.</source>
<target state="translated">Test nie powiódł się bez dodatkowego komunikatu.</target>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,11 @@
<target state="translated">Regex '--report-azdo-stackframe-filter' inválida '{0}': {1}</target>
<note>{0} is the regex pattern. {1} is the validation error message. {Locked="--report-azdo-stackframe-filter"}</note>
</trans-unit>
<trans-unit id="LogGroupHeader">
<source>Tests: {0}</source>
<target state="new">Tests: {0}</target>
<note>{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.</note>
</trans-unit>
<trans-unit id="NoFailureMessageFallback">
<source>Test failed without an additional message.</source>
<target state="translated">O teste falhou sem uma mensagem adicional.</target>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,11 @@
<target state="translated">Недопустимое регулярное выражение "--report-azdo-stackframe-filter" "{0}": {1}</target>
<note>{0} is the regex pattern. {1} is the validation error message. {Locked="--report-azdo-stackframe-filter"}</note>
</trans-unit>
<trans-unit id="LogGroupHeader">
<source>Tests: {0}</source>
<target state="new">Tests: {0}</target>
<note>{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.</note>
</trans-unit>
<trans-unit id="NoFailureMessageFallback">
<source>Test failed without an additional message.</source>
<target state="translated">Тест завершился сбоем без дополнительного сообщения.</target>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,11 @@
<target state="translated">Geçersiz '--report-azdo-stackframe-filter' normal ifade '{0}': {1}</target>
<note>{0} is the regex pattern. {1} is the validation error message. {Locked="--report-azdo-stackframe-filter"}</note>
</trans-unit>
<trans-unit id="LogGroupHeader">
<source>Tests: {0}</source>
<target state="new">Tests: {0}</target>
<note>{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.</note>
</trans-unit>
<trans-unit id="NoFailureMessageFallback">
<source>Test failed without an additional message.</source>
<target state="translated">Ek bir ileti olmadan test başarısız oldu.</target>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,11 @@
<target state="translated">'--report-azdo-stackframe-filter' 正则表达式 '{0}' 无效: {1}</target>
<note>{0} is the regex pattern. {1} is the validation error message. {Locked="--report-azdo-stackframe-filter"}</note>
</trans-unit>
<trans-unit id="LogGroupHeader">
<source>Tests: {0}</source>
<target state="new">Tests: {0}</target>
<note>{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.</note>
</trans-unit>
<trans-unit id="NoFailureMessageFallback">
<source>Test failed without an additional message.</source>
<target state="translated">测试失败,无附加消息。</target>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,11 @@
<target state="translated">無效的 '--report-azdo-stackframe-filter' regex '{0}': {1}</target>
<note>{0} is the regex pattern. {1} is the validation error message. {Locked="--report-azdo-stackframe-filter"}</note>
</trans-unit>
<trans-unit id="LogGroupHeader">
<source>Tests: {0}</source>
<target state="new">Tests: {0}</target>
<note>{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.</note>
</trans-unit>
<trans-unit id="NoFailureMessageFallback">
<source>Test failed without an additional message.</source>
<target state="translated">測試失敗,沒有其他訊息。</target>
Expand Down
Loading