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 @@ -21,6 +21,7 @@ namespace Microsoft.Testing.Platform.IPC.Serializers;
* HandshakeMessageSerializer: 9
* TestInProgressMessagesSerializer: 10
* AzureDevOpsLogMessageSerializer: 11
* DisplayMessageSerializer: 12
*/

[Embedded]
Expand All @@ -39,5 +40,6 @@ public static void RegisterAllSerializers(this NamedPipeBase namedPipeBase)
namedPipeBase.RegisterSerializer(new HandshakeMessageSerializer(), typeof(HandshakeMessage));
namedPipeBase.RegisterSerializer(new TestInProgressMessagesSerializer(), typeof(TestInProgressMessages));
namedPipeBase.RegisterSerializer(new AzureDevOpsLogMessageSerializer(), typeof(AzureDevOpsLogMessage));
namedPipeBase.RegisterSerializer(new DisplayMessageSerializer(), typeof(DisplayMessage));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

using Microsoft.Testing.Platform.Extensions.OutputDevice;
using Microsoft.Testing.Platform.Helpers;
using Microsoft.Testing.Platform.IPC;
using Microsoft.Testing.Platform.IPC.Models;
using Microsoft.Testing.Platform.ServerMode;
using Microsoft.Testing.Platform.Services;
Expand All @@ -11,19 +12,30 @@ 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.
/// <see cref="NopPlatformOutputDevice"/> it discards regular (informational) host output (the SDK's
/// TerminalTestReporter owns user-facing rendering), but it additionally forwards two classes of host message to
/// the SDK over the pipe so they are not swallowed in multi-assembly runs:
/// <list type="bullet">
/// <item>Lines marked with <see cref="AzureDevOpsCommandOutputDeviceData"/> are forwarded verbatim as
/// <see cref="AzureDevOpsLogMessage"/> so the AzureDevOpsReport extension's logging commands (##[group],
/// ##vso[...]) still reach the pipeline log (protocol 1.2.0+, only on an Azure DevOps agent where the extension
/// produces them).</item>
/// <item><see cref="WarningMessageOutputDeviceData"/> and <see cref="ErrorMessageOutputDeviceData"/> are
/// forwarded as <see cref="DisplayMessage"/> so host-side diagnostics produced outside test results (hang/crash
/// dump diagnostics, retry summaries, generic extension/framework warnings and errors) reach the SDK, which
/// routes them to its TerminalTestReporter's WriteWarningMessage / WriteErrorMessage (protocol 1.3.0+).</item>
/// </list>
/// </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>).
/// Forwarding is gated on the SDK negotiating the relevant protocol version
/// (<see cref="DotnetTestConnection.IsLogForwardingSupported"/> for Azure DevOps commands,
/// <see cref="DotnetTestConnection.IsDisplayMessageForwardingSupported"/> for display messages); against an older
/// SDK the corresponding lines are swallowed exactly like the no-op device, so no unknown message id is ever
/// sent. Informational output (plain <see cref="TextOutputDeviceData"/> / <see cref="FormattedTextOutputDeviceData"/>
/// that is not an Azure DevOps marker) is still discarded — only warnings and errors cross the wire. 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
{
Expand All @@ -38,20 +50,35 @@ public DotnetTestPassthroughOutputDevice(IServiceProvider serviceProvider)

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.";
public string Description => "Output device that discards informational host output but forwards Azure DevOps logging commands and warning/error messages 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)
// Preserve the deliberate pipe-protocol suppression: only Azure DevOps command lines and warning/error
// messages are forwarded; everything else (informational text) is swallowed exactly like
// NopPlatformOutputDevice. The switch returns early for non-forwarded data WITHOUT touching the service
// provider, so plain informational output stays as cheap as the no-op device.
switch (data)
{
return;
case AzureDevOpsCommandOutputDeviceData commandData:
await ForwardAzureDevOpsAsync(commandData, cancellationToken).ConfigureAwait(false);
break;

case ErrorMessageOutputDeviceData errorData:
await ForwardDisplayMessageAsync(DisplayMessageLevels.Error, errorData.Message, cancellationToken).ConfigureAwait(false);
break;

case WarningMessageOutputDeviceData warningData:
await ForwardDisplayMessageAsync(DisplayMessageLevels.Warning, warningData.Message, cancellationToken).ConfigureAwait(false);
break;
}
}

private async Task ForwardAzureDevOpsAsync(AzureDevOpsCommandOutputDeviceData commandData, CancellationToken cancellationToken)
{
// 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
Expand All @@ -61,11 +88,26 @@ public async Task DisplayAsync(IOutputDeviceDataProducer producer, IOutputDevice
return;
}

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

private async Task ForwardDisplayMessageAsync(byte level, string text, CancellationToken cancellationToken)
{
#pragma warning disable CA1416 // Validate platform compatibility
if (_serviceProvider.GetService<IPushOnlyProtocol>() is not DotnetTestConnection connection
|| !connection.IsDisplayMessageForwardingSupported)
{
return;
}

await connection.SendMessageAsync(new DisplayMessage(GetExecutionId(), DotnetTestConnection.InstanceId, level, text)).ConfigureAwait(false);
#pragma warning restore CA1416 // Validate platform compatibility
}

private string? GetExecutionId()
=> _serviceProvider.GetEnvironment().GetEnvironmentVariable(EnvironmentVariableConstants.TESTINGPLATFORM_DOTNETTEST_EXECUTIONID);

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

public Task DisplayBeforeSessionStartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,22 +23,22 @@ internal async Task<ProxyOutputDevice> BuildAsync(ServiceProvider serviceProvide
// 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).
// DotnetTestPassthroughOutputDevice discards informational output exactly like a no-op device, but
// forwards two classes of host message to the SDK over the protocol so they are not swallowed in
// multi-assembly runs:
// - Warning/error host messages (WarningMessageOutputDeviceData / ErrorMessageOutputDeviceData) as
// DisplayMessage (protocol 1.3.0+). This covers hang/crash dump diagnostics, retry summaries, and
// generic extension/framework warnings and errors — regardless of environment.
// - Azure DevOps logging commands (##[group], ##vso[...]) produced by the AzureDevOpsReport extension as
// AzureDevOpsLogMessage (protocol 1.2.0+). The extension only produces these on an Azure DevOps agent
// (TF_BUILD), so off-agent that branch is naturally inert; the device itself is installed everywhere so
// the warning/error forwarding above is not gated on the agent. The Azure DevOps 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.
if (isPipeProtocol)
{
IPlatformOutputDevice pipeProtocolOutputDevice =
AzureDevOpsLogIssueFormatter.IsAzureDevOpsAgent(serviceProvider.GetEnvironment())
? new DotnetTestPassthroughOutputDevice(serviceProvider)
: new NopPlatformOutputDevice();

return new ProxyOutputDevice(pipeProtocolOutputDevice, serverModeOutputDevice: null);
return new ProxyOutputDevice(new DotnetTestPassthroughOutputDevice(serviceProvider), 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 @@ -92,6 +92,11 @@ public async Task HelpInvokedAsync()
// SDK (1.0.0/1.1.0) never receives an unknown message id.
public bool IsLogForwardingSupported { get; private set; }

// True once the handshake negotiated protocol version 1.3.0 or later, which is when the SDK is able to
// receive generic DisplayMessage forwards (warning/error host diagnostics). The host gates forwarding on this
// so an older SDK (<= 1.2.0) never receives an unknown message id.
public bool IsDisplayMessageForwardingSupported { 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 @@ -130,9 +135,9 @@ public async Task<bool> IsCompatibleProtocolAsync(string hostType, IReadOnlyDict
if (response.Properties?.TryGetValue(HandshakeMessagePropertyNames.SupportedProtocolVersions, out string? protocolVersion) is true)
{
bool isCompatible = IsVersionCompatible(protocolVersion, supportedProtocolVersions);
IsLogForwardingSupported = isCompatible
&& Version.TryParse(protocolVersion, out Version? negotiatedVersion)
&& negotiatedVersion >= new Version(1, 2, 0);
bool versionParsed = Version.TryParse(protocolVersion, out Version? negotiatedVersion);
IsLogForwardingSupported = isCompatible && versionParsed && negotiatedVersion >= new Version(1, 2, 0);
IsDisplayMessageForwardingSupported = isCompatible && versionParsed && negotiatedVersion >= new Version(1, 3, 0);
return isCompatible;
}

Expand Down Expand Up @@ -174,6 +179,10 @@ public async Task SendMessageAsync(IRequest message)
case AzureDevOpsLogMessage azureDevOpsLogMessage:
await dotnetTestPipeClient.RequestReplyAsync<AzureDevOpsLogMessage, VoidResponse>(azureDevOpsLogMessage, _cancellationTokenSource.CancellationToken).ConfigureAwait(false);
break;

case DisplayMessage displayMessage:
await dotnetTestPipeClient.RequestReplyAsync<DisplayMessage, VoidResponse>(displayMessage, _cancellationTokenSource.CancellationToken).ConfigureAwait(false);
break;
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,17 @@ internal static class SessionEventTypes
internal const byte TestSessionEnd = 1;
}

[Embedded]
internal static class DisplayMessageLevels
{
// The severity of a generic host display message forwarded over the pipe. The SDK maps each level
// to its TerminalTestReporter sink: Information -> WriteMessage, Warning -> WriteWarningMessage,
// Error -> WriteErrorMessage. Values must stay stable (they flow over IPC to dotnet test).
internal const byte Information = 0;
internal const byte Warning = 1;
internal const byte Error = 2;
}

[Embedded]
internal static class HandshakeMessagePropertyNames
{
Expand Down Expand Up @@ -99,14 +110,23 @@ internal static class ProtocolConstants
// 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
// 1.3.0 adds the generic DisplayMessage: under the pipe protocol the host's forwarding output device still
// discards regular (informational) output, but relays warning/error host messages
// (WarningMessageOutputDeviceData / ErrorMessageOutputDeviceData) to the SDK as DisplayMessage so that
// host-side diagnostics produced outside test results (hang/crash dump diagnostics, retry summaries, generic
// extension/framework warnings and errors) are no longer swallowed in multi-assembly runs. The SDK routes each
// DisplayMessage to its TerminalTestReporter's WriteWarningMessage / WriteErrorMessage. Unlike the AzureDevOps
// path, this is not gated on an Azure DevOps agent. The host gates forwarding on the negotiated version, so an
// older SDK (<= 1.2.0) never receives the message.
//
// NOTE: Under the pipe protocol the host installs a forwarding output device
// (DotnetTestPassthroughOutputDevice) regardless of the negotiated protocol version (the SDK's
// TerminalTestReporter owns user-facing output). It still discards regular (informational) output but,
// depending on the negotiated version, relays: Azure DevOps logging commands as AzureDevOpsLogMessage (1.2.0+,
// only on an Azure DevOps agent) and warning/error host messages as DisplayMessage (1.3.0+, always). 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";
internal const string SupportedVersions = "1.0.0;1.1.0;1.2.0;1.3.0";
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

namespace Microsoft.Testing.Platform.IPC.Models;

// A generic host display message forwarded over the pipe so that warning/error diagnostics produced by the
// test host outside of test results (e.g. hang/crash dump diagnostics, retry summaries, generic
// extension/framework warnings and errors) reach the SDK even though the pipe protocol installs a forwarding
// output device that otherwise discards host output. Level is one of DisplayMessageLevels; the SDK maps it to
// its TerminalTestReporter sink (Information -> WriteMessage, Warning -> WriteWarningMessage,
// Error -> WriteErrorMessage). Introduced with protocol version 1.3.0; the host only sends it when the SDK
// negotiates 1.3.0 or later.
internal sealed record DisplayMessage(string? ExecutionId, string? InstanceId, byte Level, string? Text) : IRequest;
Original file line number Diff line number Diff line change
Expand Up @@ -186,3 +186,14 @@ internal static class AzureDevOpsLogMessageFieldsId
public const ushort InstanceId = 2;
public const ushort LogText = 3;
}

[Embedded]
internal static class DisplayMessageFieldsId
{
public const int MessagesSerializerId = 12;

public const ushort ExecutionId = 1;
public const ushort InstanceId = 2;
public const ushort Level = 3;
public const ushort Text = 4;
}
Loading