Skip to content
Draft
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 @@ -252,7 +252,7 @@ private async Task EmitBuildTagAsync(string tag, CancellationToken cancellationT
=> await EmitLineAsync($"{AzureDevOpsBuildAddTagCommandPrefix}{tag}", cancellationToken).ConfigureAwait(false);

private async Task EmitLineAsync(string line, CancellationToken cancellationToken)
=> await _outputDevice.DisplayAsync(this, new FormattedTextOutputDeviceData(line), cancellationToken).ConfigureAwait(false);
=> await _outputDevice.DisplayAsync(this, new AzureDevOpsCommandOutputDeviceData(line), cancellationToken).ConfigureAwait(false);

private string GetArtifactName()
=> _artifactNameOverride is { } artifactName && !RoslynString.IsNullOrWhiteSpace(artifactName)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ public async Task OnTestSessionStartingAsync(ITestSessionContext testSessionCont

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);
await _outputDevice.DisplayAsync(this, new AzureDevOpsCommandOutputDeviceData(line), testSessionContext.CancellationToken).ConfigureAwait(false);
_groupOpened = true;
}
catch (OperationCanceledException)
Expand All @@ -108,7 +108,7 @@ public async Task OnTestSessionFinishingAsync(ITestSessionContext testSessionCon
return;
}

await _outputDevice.DisplayAsync(this, new FormattedTextOutputDeviceData("##[endgroup]"), testSessionContext.CancellationToken).ConfigureAwait(false);
await _outputDevice.DisplayAsync(this, new AzureDevOpsCommandOutputDeviceData("##[endgroup]"), testSessionContext.CancellationToken).ConfigureAwait(false);
_groupOpened = false;
}
catch (OperationCanceledException)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ private async Task WriteExceptionAsync(string testDisplayName, string testName,
bool isQuarantined = _quarantineFile?.Matches(testName) == true;
if (isQuarantined && Interlocked.Exchange(ref _quarantineBuildTagEmitted, 1) == 0)
{
await _outputDisplay.DisplayAsync(this, new FormattedTextOutputDeviceData(QuarantineBuildTagLine), cancellationToken).ConfigureAwait(false);
await _outputDisplay.DisplayAsync(this, new AzureDevOpsCommandOutputDeviceData(QuarantineBuildTagLine), cancellationToken).ConfigureAwait(false);
}

string severity = GetSeverity(testName, isQuarantined);
Expand All @@ -183,7 +183,7 @@ private async Task WriteExceptionAsync(string testDisplayName, string testName,
_logger.LogTrace($"Showing failure message '{line}'.");
}

await _outputDisplay.DisplayAsync(this, new FormattedTextOutputDeviceData(line), cancellationToken).ConfigureAwait(false);
await _outputDisplay.DisplayAsync(this, new AzureDevOpsCommandOutputDeviceData(line), cancellationToken).ConfigureAwait(false);
}

internal static /* for testing */ string? GetErrorText(string testDisplayName, string? explanation, Exception? exception, string severity, IFileSystem fileSystem, ILogger logger, string targetFrameworkMoniker)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,7 @@ private async Task EmitSlowTestAsync(InProgressTest test, TimeSpan elapsed, Canc
line = $"{line} {decoration}";
}

await _outputDevice.DisplayAsync(this, new FormattedTextOutputDeviceData(line), cancellationToken).ConfigureAwait(false);
await _outputDevice.DisplayAsync(this, new AzureDevOpsCommandOutputDeviceData(line), cancellationToken).ConfigureAwait(false);
}

private TimeSpan ResolveThreshold(string testName)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,7 @@ public async Task OnTestSessionFinishingAsync(ITestSessionContext testSessionCon
}

string line = $"##vso[task.uploadsummary]{AzDoEscaper.Escape(path)}";
await _outputDevice.DisplayAsync(this, new FormattedTextOutputDeviceData(line), testSessionContext.CancellationToken).ConfigureAwait(false);
await _outputDevice.DisplayAsync(this, new AzureDevOpsCommandOutputDeviceData(line), testSessionContext.CancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ namespace Microsoft.Testing.Platform.IPC.Serializers;
* TestSessionEventSerializer: 8
* HandshakeMessageSerializer: 9
* TestInProgressMessagesSerializer: 10
* AzureDevOpsLogMessageSerializer: 11
*/

[Embedded]
Expand All @@ -37,5 +38,6 @@ public static void RegisterAllSerializers(this NamedPipeBase namedPipeBase)
namedPipeBase.RegisterSerializer(new TestSessionEventSerializer(), typeof(TestSessionEvent));
namedPipeBase.RegisterSerializer(new HandshakeMessageSerializer(), typeof(HandshakeMessage));
namedPipeBase.RegisterSerializer(new TestInProgressMessagesSerializer(), typeof(TestInProgressMessages));
namedPipeBase.RegisterSerializer(new AzureDevOpsLogMessageSerializer(), typeof(AzureDevOpsLogMessage));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// 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.Platform.OutputDevice;

/// <summary>
/// Marks an output-device line as an Azure DevOps pipeline command (for example <c>##[group]</c>,
/// <c>##[endgroup]</c> or <c>##vso[...]</c>) produced by the AzureDevOpsReport extension that must
/// reach the pipeline log even under the dotnet test pipe protocol.
/// </summary>
/// <remarks>
/// In a single-assembly run this renders like any other <see cref="TextOutputDeviceData"/> (the
/// terminal output device writes its <see cref="TextOutputDeviceData.Text"/> verbatim). Under the
/// dotnet test pipe protocol the host installs <see cref="DotnetTestPassthroughOutputDevice"/>, which
/// recognizes this marker and forwards the line to the SDK over the protocol (version 1.2.0+), so the
/// Azure DevOps logging commands are not swallowed in multi-assembly runs.
/// </remarks>
internal sealed class AzureDevOpsCommandOutputDeviceData : TextOutputDeviceData
{
public AzureDevOpsCommandOutputDeviceData(string text)
: base(text)
{
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,23 @@ internal static class AzureDevOpsLogIssueFormatter
// ##vso[task.logissue] emission even when TF_BUILD=true.
private const string OptOutEnvironmentVariableName = "TESTINGPLATFORM_AZDO_OUTPUT";

/// <summary>
/// Returns <c>true</c> when the current process is running on an Azure DevOps agent
/// (<c>TF_BUILD=true</c>), regardless of the <c>TESTINGPLATFORM_AZDO_OUTPUT</c> opt-out. Use this
/// for the AzureDevOpsReport extension's explicit <c>--report-azdo</c> output (the user opted in via
/// the option), and reserve <see cref="IsAzureDevOpsEnvironment"/> for the platform's automatic
/// <c>##vso[task.logissue]</c> emission, which the opt-out disables.
/// </summary>
public static bool IsAzureDevOpsAgent(IEnvironment environment)
=> bool.TryParse(environment.GetEnvironmentVariable("TF_BUILD"), out bool tfBuild) && tfBuild;

/// <summary>
/// Returns <c>true</c> when the current process is running on an Azure DevOps agent
/// (TF_BUILD=true) and the user has not opted out via <c>TESTINGPLATFORM_AZDO_OUTPUT=off|false|0</c>.
/// </summary>
public static bool IsAzureDevOpsEnvironment(IEnvironment environment)
{
if (!bool.TryParse(environment.GetEnvironmentVariable("TF_BUILD"), out bool tfBuild) || !tfBuild)
if (!IsAzureDevOpsAgent(environment))
{
return false;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// 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.Platform.Extensions.OutputDevice;
using Microsoft.Testing.Platform.Helpers;
using Microsoft.Testing.Platform.IPC.Models;
using Microsoft.Testing.Platform.ServerMode;
using Microsoft.Testing.Platform.Services;

namespace Microsoft.Testing.Platform.OutputDevice;

/// <summary>
/// The host output device used under the dotnet test pipe protocol. Like
/// <see cref="NopPlatformOutputDevice"/> it discards regular host output (the SDK's TerminalTestReporter
/// owns user-facing rendering), but it additionally forwards lines marked with
/// <see cref="AzureDevOpsCommandOutputDeviceData"/> to the SDK as <see cref="AzureDevOpsLogMessage"/> so
/// the AzureDevOpsReport extension's logging commands (##[group], ##vso[...]) still reach the pipeline
/// log in multi-assembly runs.
/// </summary>
/// <remarks>
/// Forwarding is gated on the SDK negotiating protocol version 1.2.0 or later
/// (<see cref="DotnetTestConnection.IsLogForwardingSupported"/>); against an older SDK the marked lines
/// are swallowed exactly like the no-op device, so no unknown message id is ever sent. The
/// <see cref="DotnetTestConnection"/> and the dotnet test execution id are resolved lazily because both
/// become available only after the output device is built (the connection is created and the execution
/// id env var is set during <c>AfterCommonServiceSetupAsync</c>).
/// </remarks>
internal sealed class DotnetTestPassthroughOutputDevice : IPlatformOutputDevice
{
private readonly IServiceProvider _serviceProvider;

public DotnetTestPassthroughOutputDevice(IServiceProvider serviceProvider)
=> _serviceProvider = serviceProvider;

public string Uid => nameof(DotnetTestPassthroughOutputDevice);

public string Version => PlatformVersion.Version;

public string DisplayName => nameof(DotnetTestPassthroughOutputDevice);

public string Description => "Output device that discards host output but forwards Azure DevOps logging commands to the SDK under the dotnet test pipe protocol.";

// Returning false keeps this device from being registered as a data consumer, matching NopPlatformOutputDevice.
public Task<bool> IsEnabledAsync() => Task.FromResult(false);

public async Task DisplayAsync(IOutputDeviceDataProducer producer, IOutputDeviceData data, CancellationToken cancellationToken)
{
// Preserve the deliberate pipe-protocol suppression: only Azure DevOps command lines are
// forwarded; everything else is swallowed exactly like NopPlatformOutputDevice.
if (data is not AzureDevOpsCommandOutputDeviceData commandData)
{
return;
}

// The dotnet test pipe protocol (DotnetTestConnection) is never active on browser, so the
// browser-unsupported members below are unreachable there; suppress CA1416 accordingly.
#pragma warning disable CA1416 // Validate platform compatibility
if (_serviceProvider.GetService<IPushOnlyProtocol>() is not DotnetTestConnection connection
|| !connection.IsLogForwardingSupported)
{
return;
}

string? executionId = _serviceProvider.GetEnvironment().GetEnvironmentVariable(EnvironmentVariableConstants.TESTINGPLATFORM_DOTNETTEST_EXECUTIONID);
await connection.SendMessageAsync(new AzureDevOpsLogMessage(executionId, DotnetTestConnection.InstanceId, commandData.Text)).ConfigureAwait(false);
#pragma warning restore CA1416 // Validate platform compatibility
}

public Task DisplayBannerAsync(string? bannerMessage, CancellationToken cancellationToken) => Task.CompletedTask;

public Task DisplayBeforeSessionStartAsync(CancellationToken cancellationToken) => Task.CompletedTask;

public Task DisplayAfterSessionEndRunAsync(CancellationToken cancellationToken) => Task.CompletedTask;

public Task HandleProcessRoleAsync(TestProcessRole processRole, CancellationToken cancellationToken) => Task.CompletedTask;
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,25 @@ public void SetPlatformOutputDevice(Func<IServiceProvider, IPlatformOutputDevice
internal async Task<ProxyOutputDevice> BuildAsync(ServiceProvider serviceProvider, bool useServerModeOutputDevice, bool isPipeProtocol)
{
// Under the dotnet test pipe protocol, the SDK's TerminalTestReporter owns all
// user-facing output, so we deliberately install a no-op device here. See #7161
// user-facing output, so the host must not produce console output of its own. See #7161
// and dotnet/sdk#51615 for the broader context.
//
// Outside Azure DevOps there is nothing to forward, so we keep the pure no-op device. Under
// Azure DevOps the AzureDevOpsReport extension produces logging commands (##[group],
// ##vso[...]) that must still reach the pipeline log; DotnetTestPassthroughOutputDevice
// forwards those marked lines to the SDK over the protocol while discarding everything else.
// The forwarder gates on the agent only (TF_BUILD), NOT the TESTINGPLATFORM_AZDO_OUTPUT opt-out:
// that opt-out is scoped to the platform's automatic ##vso[task.logissue] emission, and honoring
// it here would make multi-assembly forwarding inconsistent with single-assembly runs (where the
// extension's output is gated on TF_BUILD alone).
if (isPipeProtocol)
{
return new ProxyOutputDevice(new NopPlatformOutputDevice(), serverModeOutputDevice: null);
IPlatformOutputDevice pipeProtocolOutputDevice =
AzureDevOpsLogIssueFormatter.IsAzureDevOpsAgent(serviceProvider.GetEnvironment())
? new DotnetTestPassthroughOutputDevice(serviceProvider)
: new NopPlatformOutputDevice();

return new ProxyOutputDevice(pipeProtocolOutputDevice, serverModeOutputDevice: null);
}

// SetPlatformOutputDevice isn't public yet. Before exposing it, we should decide
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,11 @@ public async Task HelpInvokedAsync()

public bool IsIDE { get; private set; }

// True once the handshake negotiated protocol version 1.2.0 or later, which is when the SDK is
// able to receive AzureDevOpsLogMessage forwards. The host gates forwarding on this so an older
// SDK (1.0.0/1.1.0) never receives an unknown message id.
public bool IsLogForwardingSupported { get; private set; }

public async Task<bool> IsCompatibleProtocolAsync(string hostType, IReadOnlyDictionary<byte, string>? additionalHandshakeProperties = null)
{
RoslynDebug.Assert(_dotnetTestPipeClient is not null);
Expand Down Expand Up @@ -122,8 +127,16 @@ public async Task<bool> IsCompatibleProtocolAsync(string hostType, IReadOnlyDict
bool.TryParse(isIDEValue, out bool isIDE) &&
isIDE;

return response.Properties?.TryGetValue(HandshakeMessagePropertyNames.SupportedProtocolVersions, out string? protocolVersion) == true &&
IsVersionCompatible(protocolVersion, supportedProtocolVersions);
if (response.Properties?.TryGetValue(HandshakeMessagePropertyNames.SupportedProtocolVersions, out string? protocolVersion) == true)
{
bool isCompatible = IsVersionCompatible(protocolVersion, supportedProtocolVersions);
IsLogForwardingSupported = isCompatible
&& Version.TryParse(protocolVersion, out Version? negotiatedVersion)
&& negotiatedVersion >= new Version(1, 2, 0);
return isCompatible;
}

return false;
}

private string GetExecutionMode()
Expand Down Expand Up @@ -156,6 +169,10 @@ public async Task SendMessageAsync(IRequest message)
case TestSessionEvent testSessionEvent:
await _dotnetTestPipeClient.RequestReplyAsync<TestSessionEvent, VoidResponse>(testSessionEvent, _cancellationTokenSource.CancellationToken).ConfigureAwait(false);
break;

case AzureDevOpsLogMessage azureDevOpsLogMessage:
await _dotnetTestPipeClient.RequestReplyAsync<AzureDevOpsLogMessage, VoidResponse>(azureDevOpsLogMessage, _cancellationTokenSource.CancellationToken).ConfigureAwait(false);
break;
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,10 +92,21 @@ internal static class ProtocolConstants
// When both sides advertise 1.1.0 and we negotiate to that version, the SDK can keep its
// live output enabled.
//
// NOTE: The no-op output device is installed for all pipe-protocol connections, regardless
// of the negotiated protocol version. With an old SDK that only supports 1.0.0, both sides
// will produce no live output (the SDK suppresses its TerminalTestReporter to avoid colliding
// with the host output it expected before this change). Users must update to an SDK that
// negotiates 1.1.0 to see live output via the SDK's TerminalTestReporter.
internal const string SupportedVersions = "1.0.0;1.1.0";
// 1.2.0 adds the AzureDevOpsLogMessage: under the pipe protocol the host installs a no-op output
// device (see below), so any Azure DevOps logging commands (##[group], ##vso[...]) produced by the
// AzureDevOpsReport extension would otherwise be swallowed. When both sides negotiate 1.2.0 the host
// forwards those marked lines to the SDK over the pipe, and the SDK writes them verbatim to its
// TerminalTestReporter so they reach the pipeline log. An older SDK that only negotiates 1.1.0 never
// receives the message (the host gates forwarding on the negotiated version), so it stays compatible.
//
// NOTE: Under the pipe protocol the host installs a no-op output device for regular output
// regardless of the negotiated protocol version (the SDK's TerminalTestReporter owns user-facing
// output). The sole exception is when running on an Azure DevOps agent with a negotiated version of
// 1.2.0 or later: the host then installs a forwarder that still discards regular output but relays
// Azure DevOps logging commands as AzureDevOpsLogMessage (see OutputDeviceManager.BuildAsync).
// With an old SDK that only supports 1.0.0, both sides will produce no live output (the SDK
// suppresses its TerminalTestReporter to avoid colliding with the host output it expected before
// this change). Users must update to an SDK that negotiates 1.1.0 to see live output via the SDK's
// TerminalTestReporter.
internal const string SupportedVersions = "1.0.0;1.1.0;1.2.0";
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// 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.Platform.IPC.Models;

// A single line of Azure DevOps "passthrough" output produced by the AzureDevOpsReport extension
// (e.g. ##[group], ##[endgroup], ##vso[...]) that must reach the pipeline log even though the pipe
// protocol installs a no-op output device on the host. The SDK writes LogText verbatim to its
// TerminalTestReporter. Introduced with protocol version 1.2.0; the host only sends it when the SDK
// negotiates 1.2.0 or later.
internal sealed record AzureDevOpsLogMessage(string? ExecutionId, string? InstanceId, string? LogText) : IRequest;
Original file line number Diff line number Diff line change
Expand Up @@ -176,3 +176,13 @@ internal static class TestInProgressMessageFieldsId
public const ushort Uid = 1;
public const ushort DisplayName = 2;
}

[Embedded]
internal static class AzureDevOpsLogMessageFieldsId
{
public const int MessagesSerializerId = 11;

public const ushort ExecutionId = 1;
public const ushort InstanceId = 2;
public const ushort LogText = 3;
}
Loading
Loading