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 @@ -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,25 @@
// 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 produced by the AzureDevOpsReport extension that must reach the Azure
/// DevOps pipeline log even under the dotnet test pipe protocol. This is usually an Azure DevOps logging
/// command (for example <c>##[group]</c>, <c>##[endgroup]</c> or <c>##vso[...]</c>), but it also carries
/// the extension's other report output, such as the slow-test progress lines.
/// </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
/// AzureDevOpsReport output is 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) is 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 All @@ -137,24 +150,29 @@ private string GetExecutionMode()

public async Task SendMessageAsync(IRequest message)
{
RoslynDebug.Assert(_dotnetTestPipeClient is not null);
NamedPipeClient dotnetTestPipeClient = _dotnetTestPipeClient
?? throw new InvalidOperationException("The dotnet test pipe client is not connected.");

switch (message)
{
case DiscoveredTestMessages discoveredTestMessages:
await _dotnetTestPipeClient.RequestReplyAsync<DiscoveredTestMessages, VoidResponse>(discoveredTestMessages, _cancellationTokenSource.CancellationToken).ConfigureAwait(false);
await dotnetTestPipeClient.RequestReplyAsync<DiscoveredTestMessages, VoidResponse>(discoveredTestMessages, _cancellationTokenSource.CancellationToken).ConfigureAwait(false);
break;

case TestResultMessages testResultMessages:
await _dotnetTestPipeClient.RequestReplyAsync<TestResultMessages, VoidResponse>(testResultMessages, _cancellationTokenSource.CancellationToken).ConfigureAwait(false);
await dotnetTestPipeClient.RequestReplyAsync<TestResultMessages, VoidResponse>(testResultMessages, _cancellationTokenSource.CancellationToken).ConfigureAwait(false);
break;

case FileArtifactMessages fileArtifactMessages:
await _dotnetTestPipeClient.RequestReplyAsync<FileArtifactMessages, VoidResponse>(fileArtifactMessages, _cancellationTokenSource.CancellationToken).ConfigureAwait(false);
await dotnetTestPipeClient.RequestReplyAsync<FileArtifactMessages, VoidResponse>(fileArtifactMessages, _cancellationTokenSource.CancellationToken).ConfigureAwait(false);
break;

case TestSessionEvent testSessionEvent:
await _dotnetTestPipeClient.RequestReplyAsync<TestSessionEvent, VoidResponse>(testSessionEvent, _cancellationTokenSource.CancellationToken).ConfigureAwait(false);
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
Loading
Loading