From 915c522a5e821cc5ad46a8c0940d6652b70ec5b0 Mon Sep 17 00:00:00 2001 From: amauryleve Date: Fri, 26 Jun 2026 18:34:29 +0200 Subject: [PATCH] Forward Azure DevOps logging commands over the dotnet test pipe (multi-assembly) Under the dotnet test pipe protocol the host installs a no-op output device (the SDK's TerminalTestReporter owns user-facing output), so the AzureDevOpsReport extension's logging commands (##[group], ##vso[...]) were swallowed in multi-assembly runs. This adds protocol version 1.2.0 with a new AzureDevOpsLogMessage (serializer id 11): on an Azure DevOps agent with a negotiated version >= 1.2.0, those marked lines are forwarded to the SDK instead of dropped, while all other host output stays suppressed. Single-assembly behavior is unchanged. The SDK-side consumption (deserialize -> TerminalTestReporter.WriteMessage) is a separate follow-up. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AzureDevOpsArtifactUploader.cs | 2 +- .../AzureDevOpsLogGroupReporter.cs | 4 +- .../AzureDevOpsReporter.cs | 4 +- .../AzureDevOpsSlowTestReporter.cs | 2 +- .../AzureDevOpsSummaryReporter.cs | 2 +- .../IPC/Serializers/RegisterSerializers.cs | 2 + .../AzureDevOpsCommandOutputDeviceData.cs | 24 +++ .../AzureDevOpsLogIssueFormatter.cs | 12 +- .../DotnetTestPassthroughOutputDevice.cs | 76 +++++++ .../OutputDevice/OutputDeviceManager.cs | 18 +- .../DotnetTest/DotnetTestConnection.cs | 21 +- .../ServerMode/DotnetTest/IPC/Constants.cs | 23 ++- .../IPC/Models/AzureDevOpsLogMessage.cs | 11 ++ .../DotnetTest/IPC/ObjectFieldIds.cs | 10 + .../AzureDevOpsLogMessageSerializer.cs | 80 ++++++++ ...otnetTestPipeAzureDevOpsForwardingTests.cs | 186 ++++++++++++++++++ .../DotnetTestPipeBaselineTests.cs | 18 +- .../DotnetTestPipe/DotnetTestPipeProtocol.cs | 46 +++++ .../AzureDevOpsArtifactUploaderTests.cs | 2 +- .../AzureDevOpsLogGroupReporterTests.cs | 2 +- .../AzureDevOpsSummaryReporterTests.cs | 2 +- .../DotnetTestProtocolContractTests.cs | 4 +- .../IPC/ProtocolTests.cs | 30 ++- .../AzureDevOpsLogIssueFormatterTests.cs | 33 ++++ .../DotnetTestPassthroughOutputDeviceTests.cs | 51 +++++ 25 files changed, 633 insertions(+), 32 deletions(-) create mode 100644 src/Platform/Microsoft.Testing.Platform/OutputDevice/AzureDevOpsCommandOutputDeviceData.cs create mode 100644 src/Platform/Microsoft.Testing.Platform/OutputDevice/DotnetTestPassthroughOutputDevice.cs create mode 100644 src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/Models/AzureDevOpsLogMessage.cs create mode 100644 src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/Serializers/AzureDevOpsLogMessageSerializer.cs create mode 100644 test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/DotnetTestPipe/DotnetTestPipeAzureDevOpsForwardingTests.cs create mode 100644 test/UnitTests/Microsoft.Testing.Platform.UnitTests/OutputDevice/DotnetTestPassthroughOutputDeviceTests.cs diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsArtifactUploader.cs b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsArtifactUploader.cs index 2cef8734c2..78aa165da1 100644 --- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsArtifactUploader.cs +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsArtifactUploader.cs @@ -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) diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsLogGroupReporter.cs b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsLogGroupReporter.cs index 8f6521b2f7..711ba1cd3e 100644 --- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsLogGroupReporter.cs +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsLogGroupReporter.cs @@ -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) @@ -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) diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsReporter.cs b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsReporter.cs index 43c7f0421b..b8595b44f9 100644 --- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsReporter.cs +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsReporter.cs @@ -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); @@ -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) diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsSlowTestReporter.cs b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsSlowTestReporter.cs index b10b0c1353..d3de325f48 100644 --- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsSlowTestReporter.cs +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsSlowTestReporter.cs @@ -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) diff --git a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsSummaryReporter.cs b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsSummaryReporter.cs index 127b9e1e41..169fc9a8ec 100644 --- a/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsSummaryReporter.cs +++ b/src/Platform/Microsoft.Testing.Extensions.AzureDevOpsReport/AzureDevOpsSummaryReporter.cs @@ -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) { diff --git a/src/Platform/Microsoft.Testing.Platform/IPC/Serializers/RegisterSerializers.cs b/src/Platform/Microsoft.Testing.Platform/IPC/Serializers/RegisterSerializers.cs index 3fc7d6cfe0..59b204f03d 100644 --- a/src/Platform/Microsoft.Testing.Platform/IPC/Serializers/RegisterSerializers.cs +++ b/src/Platform/Microsoft.Testing.Platform/IPC/Serializers/RegisterSerializers.cs @@ -20,6 +20,7 @@ namespace Microsoft.Testing.Platform.IPC.Serializers; * TestSessionEventSerializer: 8 * HandshakeMessageSerializer: 9 * TestInProgressMessagesSerializer: 10 + * AzureDevOpsLogMessageSerializer: 11 */ [Embedded] @@ -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)); } } diff --git a/src/Platform/Microsoft.Testing.Platform/OutputDevice/AzureDevOpsCommandOutputDeviceData.cs b/src/Platform/Microsoft.Testing.Platform/OutputDevice/AzureDevOpsCommandOutputDeviceData.cs new file mode 100644 index 0000000000..85fd30b229 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Platform/OutputDevice/AzureDevOpsCommandOutputDeviceData.cs @@ -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; + +/// +/// Marks an output-device line as an Azure DevOps pipeline command (for example ##[group], +/// ##[endgroup] or ##vso[...]) produced by the AzureDevOpsReport extension that must +/// reach the pipeline log even under the dotnet test pipe protocol. +/// +/// +/// In a single-assembly run this renders like any other (the +/// terminal output device writes its verbatim). Under the +/// dotnet test pipe protocol the host installs , 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. +/// +internal sealed class AzureDevOpsCommandOutputDeviceData : TextOutputDeviceData +{ + public AzureDevOpsCommandOutputDeviceData(string text) + : base(text) + { + } +} diff --git a/src/Platform/Microsoft.Testing.Platform/OutputDevice/AzureDevOpsLogIssueFormatter.cs b/src/Platform/Microsoft.Testing.Platform/OutputDevice/AzureDevOpsLogIssueFormatter.cs index a129716ffb..d615d2c0c3 100644 --- a/src/Platform/Microsoft.Testing.Platform/OutputDevice/AzureDevOpsLogIssueFormatter.cs +++ b/src/Platform/Microsoft.Testing.Platform/OutputDevice/AzureDevOpsLogIssueFormatter.cs @@ -21,13 +21,23 @@ internal static class AzureDevOpsLogIssueFormatter // ##vso[task.logissue] emission even when TF_BUILD=true. private const string OptOutEnvironmentVariableName = "TESTINGPLATFORM_AZDO_OUTPUT"; + /// + /// Returns true when the current process is running on an Azure DevOps agent + /// (TF_BUILD=true), regardless of the TESTINGPLATFORM_AZDO_OUTPUT opt-out. Use this + /// for the AzureDevOpsReport extension's explicit --report-azdo output (the user opted in via + /// the option), and reserve for the platform's automatic + /// ##vso[task.logissue] emission, which the opt-out disables. + /// + public static bool IsAzureDevOpsAgent(IEnvironment environment) + => bool.TryParse(environment.GetEnvironmentVariable("TF_BUILD"), out bool tfBuild) && tfBuild; + /// /// Returns true when the current process is running on an Azure DevOps agent /// (TF_BUILD=true) and the user has not opted out via TESTINGPLATFORM_AZDO_OUTPUT=off|false|0. /// public static bool IsAzureDevOpsEnvironment(IEnvironment environment) { - if (!bool.TryParse(environment.GetEnvironmentVariable("TF_BUILD"), out bool tfBuild) || !tfBuild) + if (!IsAzureDevOpsAgent(environment)) { return false; } diff --git a/src/Platform/Microsoft.Testing.Platform/OutputDevice/DotnetTestPassthroughOutputDevice.cs b/src/Platform/Microsoft.Testing.Platform/OutputDevice/DotnetTestPassthroughOutputDevice.cs new file mode 100644 index 0000000000..3b0900a226 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Platform/OutputDevice/DotnetTestPassthroughOutputDevice.cs @@ -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; + +/// +/// The host output device used under the dotnet test pipe protocol. Like +/// it discards regular host output (the SDK's TerminalTestReporter +/// owns user-facing rendering), but it additionally forwards lines marked with +/// to the SDK as so +/// the AzureDevOpsReport extension's logging commands (##[group], ##vso[...]) still reach the pipeline +/// log in multi-assembly runs. +/// +/// +/// Forwarding is gated on the SDK negotiating protocol version 1.2.0 or later +/// (); against an older SDK the marked lines +/// are swallowed exactly like the no-op device, so no unknown message id is ever sent. The +/// 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 AfterCommonServiceSetupAsync). +/// +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 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() 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; +} diff --git a/src/Platform/Microsoft.Testing.Platform/OutputDevice/OutputDeviceManager.cs b/src/Platform/Microsoft.Testing.Platform/OutputDevice/OutputDeviceManager.cs index 74158f1b1f..4ad3c62a45 100644 --- a/src/Platform/Microsoft.Testing.Platform/OutputDevice/OutputDeviceManager.cs +++ b/src/Platform/Microsoft.Testing.Platform/OutputDevice/OutputDeviceManager.cs @@ -20,11 +20,25 @@ public void SetPlatformOutputDevice(Func 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 diff --git a/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/DotnetTestConnection.cs b/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/DotnetTestConnection.cs index 210592403e..f00be50ec4 100644 --- a/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/DotnetTestConnection.cs +++ b/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/DotnetTestConnection.cs @@ -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 IsCompatibleProtocolAsync(string hostType, IReadOnlyDictionary? additionalHandshakeProperties = null) { RoslynDebug.Assert(_dotnetTestPipeClient is not null); @@ -122,8 +127,16 @@ public async Task 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() @@ -156,6 +169,10 @@ public async Task SendMessageAsync(IRequest message) case TestSessionEvent testSessionEvent: await _dotnetTestPipeClient.RequestReplyAsync(testSessionEvent, _cancellationTokenSource.CancellationToken).ConfigureAwait(false); break; + + case AzureDevOpsLogMessage azureDevOpsLogMessage: + await _dotnetTestPipeClient.RequestReplyAsync(azureDevOpsLogMessage, _cancellationTokenSource.CancellationToken).ConfigureAwait(false); + break; } } diff --git a/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/Constants.cs b/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/Constants.cs index 01c0815f79..908b9dbaff 100644 --- a/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/Constants.cs +++ b/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/Constants.cs @@ -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"; } diff --git a/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/Models/AzureDevOpsLogMessage.cs b/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/Models/AzureDevOpsLogMessage.cs new file mode 100644 index 0000000000..9445b3309d --- /dev/null +++ b/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/Models/AzureDevOpsLogMessage.cs @@ -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; diff --git a/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/ObjectFieldIds.cs b/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/ObjectFieldIds.cs index 462f233d8f..b8fd577768 100644 --- a/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/ObjectFieldIds.cs +++ b/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/ObjectFieldIds.cs @@ -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; +} diff --git a/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/Serializers/AzureDevOpsLogMessageSerializer.cs b/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/Serializers/AzureDevOpsLogMessageSerializer.cs new file mode 100644 index 0000000000..e5f568d11a --- /dev/null +++ b/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/Serializers/AzureDevOpsLogMessageSerializer.cs @@ -0,0 +1,80 @@ +// 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.IPC.Models; + +namespace Microsoft.Testing.Platform.IPC.Serializers; + +/* + |---FieldCount---| 2 bytes + + |---ExecutionId Id---| (2 bytes) + |---ExecutionId Size---| (4 bytes) + |---ExecutionId Value---| (n bytes) + + |---InstanceId Id---| (2 bytes) + |---InstanceId Size---| (4 bytes) + |---InstanceId Value---| (n bytes) + + |---LogText Id---| (2 bytes) + |---LogText Size---| (4 bytes) + |---LogText Value---| (n bytes) +*/ + +internal sealed class AzureDevOpsLogMessageSerializer : NamedPipeSerializer, INamedPipeSerializer +{ + public override int Id => AzureDevOpsLogMessageFieldsId.MessagesSerializerId; + + protected override AzureDevOpsLogMessage DeserializeCore(Stream stream) + { + string? executionId = null; + string? instanceId = null; + string? logText = null; + + ushort fieldCount = ReadUShort(stream); + + for (int i = 0; i < fieldCount; i++) + { + ushort fieldId = ReadUShort(stream); + int fieldSize = ReadInt(stream); + + switch (fieldId) + { + case AzureDevOpsLogMessageFieldsId.ExecutionId: + executionId = ReadStringValue(stream, fieldSize); + break; + + case AzureDevOpsLogMessageFieldsId.InstanceId: + instanceId = ReadStringValue(stream, fieldSize); + break; + + case AzureDevOpsLogMessageFieldsId.LogText: + logText = ReadStringValue(stream, fieldSize); + break; + + default: + // If we don't recognize the field id, skip the payload corresponding to that field + SetPosition(stream, stream.Position + fieldSize); + break; + } + } + + return new(executionId, instanceId, logText); + } + + protected override void SerializeCore(AzureDevOpsLogMessage objectToSerialize, Stream stream) + { + RoslynDebug.Assert(stream.CanSeek, "We expect a seekable stream."); + + WriteUShort(stream, GetFieldCount(objectToSerialize)); + + WriteField(stream, AzureDevOpsLogMessageFieldsId.ExecutionId, objectToSerialize.ExecutionId); + WriteField(stream, AzureDevOpsLogMessageFieldsId.InstanceId, objectToSerialize.InstanceId); + WriteField(stream, AzureDevOpsLogMessageFieldsId.LogText, objectToSerialize.LogText); + } + + private static ushort GetFieldCount(AzureDevOpsLogMessage message) => + (ushort)((message.ExecutionId is null ? 0 : 1) + + (message.InstanceId is null ? 0 : 1) + + (message.LogText is null ? 0 : 1)); +} diff --git a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/DotnetTestPipe/DotnetTestPipeAzureDevOpsForwardingTests.cs b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/DotnetTestPipe/DotnetTestPipeAzureDevOpsForwardingTests.cs new file mode 100644 index 0000000000..1c94330adb --- /dev/null +++ b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/DotnetTestPipe/DotnetTestPipeAzureDevOpsForwardingTests.cs @@ -0,0 +1,186 @@ +// 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.Acceptance.IntegrationTests.DotnetTestPipe; + +/// +/// Acceptance tests for forwarding Azure DevOps logging commands over the +/// --server dotnettestcli --dotnet-test-pipe protocol. +/// +/// Under the pipe protocol the host installs a no-op output device, so the AzureDevOpsReport +/// extension's logging commands (##[group], ##vso[...]) would otherwise be swallowed +/// in multi-assembly runs. Protocol 1.2.0 adds AzureDevOpsLogMessage (serializer id 11): when +/// both sides negotiate 1.2.0 and the host is running on an Azure DevOps agent, those marked lines are +/// forwarded to the SDK instead of being dropped. These tests assert that contract on the wire via the +/// black-box harness. +/// +/// +[TestClass] +public sealed class DotnetTestPipeAzureDevOpsForwardingTests : AcceptanceTestBase +{ + private const string AssetName = "DotnetTestPipeAzureDevOpsForwarding"; + + // Mirrors ProtocolConstants.SupportedVersions on the host side: the host advertises 1.2.0, the + // version that introduced AzureDevOpsLogMessage forwarding. + private const string HostAdvertisedProtocolVersions = "1.0.0;1.1.0;1.2.0"; + + public TestContext TestContext { get; set; } = null!; + + [TestMethod] + public async Task DotnetTestPipe_WhenSdkSupports120AndRunningInAzureDevOps_ForwardsLogGroupCommands() + { + var testHost = TestInfrastructure.TestHost.LocateFrom( + AssetFixture.TargetAssetPath, AssetName, TargetFrameworks.NetCurrent); + + FakeDotnetTestSdkResult result = await FakeDotnetTestSdk.RunAsync( + testHost, + extraArguments: "--report-azdo", + environmentVariables: new Dictionary { ["TF_BUILD"] = "true" }, + supportedProtocolVersions: HostAdvertisedProtocolVersions, + cancellationToken: TestContext.CancellationToken); + + Assert.AreEqual( + "1.2.0", + result.NegotiatedProtocolVersion, + "When both sides advertise 1.2.0, negotiation should select 1.2.0 to enable Azure DevOps log forwarding."); + + string[] forwardedLines = GetForwardedAzureDevOpsLines(result); + + Assert.Contains( + line => line.StartsWith("##[group]", StringComparison.Ordinal), + forwardedLines, + $"Expected a forwarded '##[group]' command. Forwarded lines: [{string.Join(" | ", forwardedLines)}]."); + Assert.Contains( + "##[endgroup]", + forwardedLines, + $"Expected a forwarded '##[endgroup]' command. Forwarded lines: [{string.Join(" | ", forwardedLines)}]."); + + // The forwarded frames carry the same InstanceId as the handshake so the SDK can correlate the + // logging commands back to the originating assembly in a multi-assembly run. + Assert.IsNotNull(result.ReceivedHandshake); + Assert.IsTrue(result.ReceivedHandshake.TryGetValue(DotnetTestPipeProtocol.HandshakeProperties.InstanceId, out string? handshakeInstanceId)); + string[] forwardedInstanceIds = [.. + result.MessagesWithSerializerId(DotnetTestPipeProtocol.SerializerIds.AzureDevOpsLogMessage) + .Select(m => DotnetTestPipeProtocol.DecodeAzureDevOpsLogMessageBody(m.Body).InstanceId) + .Where(id => id is not null) + .Select(id => id!) + .Distinct()]; + string onlyInstanceId = Assert.ContainsSingle(forwardedInstanceIds); + Assert.AreEqual(handshakeInstanceId, onlyInstanceId); + } + + [TestMethod] + public async Task DotnetTestPipe_WhenSdkOnlySupports110_DoesNotForwardLogMessages() + { + var testHost = TestInfrastructure.TestHost.LocateFrom( + AssetFixture.TargetAssetPath, AssetName, TargetFrameworks.NetCurrent); + + // An older SDK that does not understand AzureDevOpsLogMessage advertises only up to 1.1.0. + FakeDotnetTestSdkResult result = await FakeDotnetTestSdk.RunAsync( + testHost, + extraArguments: "--report-azdo", + environmentVariables: new Dictionary { ["TF_BUILD"] = "true" }, + supportedProtocolVersions: "1.0.0;1.1.0", + cancellationToken: TestContext.CancellationToken); + + Assert.AreEqual( + "1.1.0", + result.NegotiatedProtocolVersion, + "An SDK that supports up to 1.1.0 should negotiate down to 1.1.0."); + + Assert.IsEmpty( + result.MessagesWithSerializerId(DotnetTestPipeProtocol.SerializerIds.AzureDevOpsLogMessage), + "No AzureDevOpsLogMessage should be forwarded when the negotiated protocol is below 1.2.0."); + } + + [TestMethod] + public async Task DotnetTestPipe_WhenNotRunningInAzureDevOps_DoesNotForwardLogMessages() + { + var testHost = TestInfrastructure.TestHost.LocateFrom( + AssetFixture.TargetAssetPath, AssetName, TargetFrameworks.NetCurrent); + + // 1.2.0 is negotiated, but without TF_BUILD the host is not on an Azure DevOps agent, so the + // AzureDevOpsReport extension produces nothing and the forwarder stays a no-op. + FakeDotnetTestSdkResult result = await FakeDotnetTestSdk.RunAsync( + testHost, + extraArguments: "--report-azdo", + supportedProtocolVersions: HostAdvertisedProtocolVersions, + cancellationToken: TestContext.CancellationToken); + + Assert.AreEqual("1.2.0", result.NegotiatedProtocolVersion); + + Assert.IsEmpty( + result.MessagesWithSerializerId(DotnetTestPipeProtocol.SerializerIds.AzureDevOpsLogMessage), + "No AzureDevOpsLogMessage should be forwarded when not running on an Azure DevOps agent."); + } + + private static string[] GetForwardedAzureDevOpsLines(FakeDotnetTestSdkResult result) + => [.. + result.MessagesWithSerializerId(DotnetTestPipeProtocol.SerializerIds.AzureDevOpsLogMessage) + .Select(m => DotnetTestPipeProtocol.DecodeAzureDevOpsLogMessageBody(m.Body).LogText) + .Where(text => text is not null) + .Select(text => text!)]; + + public sealed class TestAssetFixture() : TestAssetFixtureBase() + { + private const string Sources = """ +#file DotnetTestPipeAzureDevOpsForwarding.csproj + + + $TargetFrameworks$ + Exe + enable + enable + true + preview + + + + + + +#file Program.cs +using Microsoft.Testing.Extensions; +using Microsoft.Testing.Platform.Builder; +using Microsoft.Testing.Platform.Capabilities.TestFramework; +using Microsoft.Testing.Platform.Extensions.TestFramework; + +public class Program +{ + public static async Task Main(string[] args) + { + ITestApplicationBuilder builder = await TestApplication.CreateBuilderAsync(args); + builder.RegisterTestFramework(_ => new TestFrameworkCapabilities(), (_, __) => new DummyTestFramework()); + builder.AddAzureDevOpsProvider(); + using ITestApplication app = await builder.BuildAsync(); + return await app.RunAsync(); + } +} + +public class DummyTestFramework : ITestFramework +{ + public string Uid => nameof(DummyTestFramework); + public string Version => "2.0.0"; + public string DisplayName => nameof(DummyTestFramework); + public string Description => nameof(DummyTestFramework); + public Task IsEnabledAsync() => Task.FromResult(true); + public Task CreateTestSessionAsync(CreateTestSessionContext context) + => Task.FromResult(new CreateTestSessionResult() { IsSuccess = true }); + public Task CloseTestSessionAsync(CloseTestSessionContext context) + => Task.FromResult(new CloseTestSessionResult() { IsSuccess = true }); + public Task ExecuteRequestAsync(ExecuteRequestContext context) + { + context.Complete(); + return Task.CompletedTask; + } +} +"""; + + public string TargetAssetPath => GetAssetPath(AssetName); + + public override (string ID, string Name, string Code) GetAssetsToGenerate() => (AssetName, AssetName, + Sources + .PatchTargetFrameworks(TargetFrameworks.NetCurrent) + .PatchCodeWithReplace("$MicrosoftTestingPlatformVersion$", MicrosoftTestingPlatformVersion)); + } +} diff --git a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/DotnetTestPipe/DotnetTestPipeBaselineTests.cs b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/DotnetTestPipe/DotnetTestPipeBaselineTests.cs index a7a86dcb30..c95250a441 100644 --- a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/DotnetTestPipe/DotnetTestPipeBaselineTests.cs +++ b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/DotnetTestPipe/DotnetTestPipeBaselineTests.cs @@ -25,11 +25,11 @@ public class DotnetTestPipeBaselineTests : AcceptanceTestBase /// Computes the OS-level named pipe name from a friendly identifier. Mirrors /// NamedPipeServer.GetPipeName in testfx. @@ -221,6 +229,44 @@ public static (byte? SessionType, string? SessionUid, string? ExecutionId) Decod return (sessionType, sessionUid, executionId); } + /// + /// Decodes the body of a frame. + /// Format: ushort fieldCount; (ushort fieldId, int fieldSize, payload)*fieldCount + /// where every field is a length-prefixed UTF-8 string. Returns null for absent fields. + /// + public static (string? ExecutionId, string? InstanceId, string? LogText) DecodeAzureDevOpsLogMessageBody(byte[] body) + { + string? executionId = null; + string? instanceId = null; + string? logText = null; + + using MemoryStream stream = new(body, writable: false); + ushort fieldCount = ReadUShort(stream); + for (int i = 0; i < fieldCount; i++) + { + ushort fieldId = ReadUShort(stream); + int fieldSize = ReadInt(stream); + + switch (fieldId) + { + case AzureDevOpsLogMessageFields.ExecutionId: + executionId = ReadFixedSizeString(stream, fieldSize); + break; + case AzureDevOpsLogMessageFields.InstanceId: + instanceId = ReadFixedSizeString(stream, fieldSize); + break; + case AzureDevOpsLogMessageFields.LogText: + logText = ReadFixedSizeString(stream, fieldSize); + break; + default: + stream.Seek(fieldSize, SeekOrigin.Current); + break; + } + } + + return (executionId, instanceId, logText); + } + /// /// Decodes the tests carried by a frame, /// including the full discovery details (file path, line number, namespace, type/method name, diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/AzureDevOpsArtifactUploaderTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/AzureDevOpsArtifactUploaderTests.cs index 703e879aaa..b6a8ef4e32 100644 --- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/AzureDevOpsArtifactUploaderTests.cs +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/AzureDevOpsArtifactUploaderTests.cs @@ -428,7 +428,7 @@ private static string OutsideResults(params string[] segments) => segments.Aggregate(OutsideResultsDirectory, Path.Combine); private string[] GetFormattedLines() - => [.. _outputData.OfType().Select(output => output.Text)]; + => [.. _outputData.OfType().Select(output => output.Text)]; private string[] GetWarnings() => [.. _outputData.OfType().Select(output => output.Message)]; diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/AzureDevOpsLogGroupReporterTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/AzureDevOpsLogGroupReporterTests.cs index 4e9d2826a3..5342c31d73 100644 --- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/AzureDevOpsLogGroupReporterTests.cs +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/AzureDevOpsLogGroupReporterTests.cs @@ -137,7 +137,7 @@ private AzureDevOpsLogGroupReporter CreateReporter(bool enabled, bool tfBuild) } private string[] GetFormattedLines() - => [.. _outputData.OfType().Select(output => output.Text)]; + => [.. _outputData.OfType().Select(output => output.Text)]; private sealed class TestSessionContext : ITestSessionContext { diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/AzureDevOpsSummaryReporterTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/AzureDevOpsSummaryReporterTests.cs index cf888f7772..e62f069941 100644 --- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/AzureDevOpsSummaryReporterTests.cs +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/AzureDevOpsSummaryReporterTests.cs @@ -199,7 +199,7 @@ private static TestNodeUpdateMessage Create(string uid, TestNodeStateProperty st private static IDataProducer CreateProducer() => new TestProducer(); private string[] GetFormattedLines() - => [.. _outputData.OfType().Select(output => output.Text)]; + => [.. _outputData.OfType().Select(output => output.Text)]; private string[] GetWarnings() => [.. _outputData.OfType().Select(output => output.Message)]; diff --git a/test/UnitTests/Microsoft.Testing.Platform.DotnetTestProtocolContract.UnitTests/DotnetTestProtocolContractTests.cs b/test/UnitTests/Microsoft.Testing.Platform.DotnetTestProtocolContract.UnitTests/DotnetTestProtocolContractTests.cs index a005d1d3d1..a49aa654bc 100644 --- a/test/UnitTests/Microsoft.Testing.Platform.DotnetTestProtocolContract.UnitTests/DotnetTestProtocolContractTests.cs +++ b/test/UnitTests/Microsoft.Testing.Platform.DotnetTestProtocolContract.UnitTests/DotnetTestProtocolContractTests.cs @@ -37,6 +37,7 @@ public void SerializerIds_AreStable() [TestSessionEventFieldsId.MessagesSerializerId] = nameof(TestSessionEventFieldsId), [HandshakeMessageFieldsId.MessagesSerializerId] = nameof(HandshakeMessageFieldsId), [TestInProgressMessagesFieldsId.MessagesSerializerId] = nameof(TestInProgressMessagesFieldsId), + [AzureDevOpsLogMessageFieldsId.MessagesSerializerId] = nameof(AzureDevOpsLogMessageFieldsId), }; Assert.AreEqual(nameof(VoidResponseFieldsId), serializerIds[0]); @@ -51,6 +52,7 @@ public void SerializerIds_AreStable() Assert.AreEqual(nameof(TestSessionEventFieldsId), serializerIds[8]); Assert.AreEqual(nameof(HandshakeMessageFieldsId), serializerIds[9]); Assert.AreEqual(nameof(TestInProgressMessagesFieldsId), serializerIds[10]); + Assert.AreEqual(nameof(AzureDevOpsLogMessageFieldsId), serializerIds[11]); } [TestMethod] @@ -138,6 +140,6 @@ public void ProtocolVersion_IsStable() // Indirect through a collection so the MSTest analyzer does not flag the comparison of a compile-time // constant as "always true" (MSTEST0032). string[] versions = [ProtocolConstants.SupportedVersions]; - Assert.AreEqual("1.0.0;1.1.0", versions[0]); + Assert.AreEqual("1.0.0;1.1.0;1.2.0", versions[0]); } } diff --git a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/IPC/ProtocolTests.cs b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/IPC/ProtocolTests.cs index a3ffdffcb4..ab8f3ee485 100644 --- a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/IPC/ProtocolTests.cs +++ b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/IPC/ProtocolTests.cs @@ -31,6 +31,32 @@ public void TestResultMessagesSerializeDeserialize() Assert.AreEqual(message.FailedTestMessages?[0].Exceptions?[0].ErrorMessage, actual.FailedTestMessages?[0].Exceptions?[0].ErrorMessage); } + [TestMethod] + public void AzureDevOpsLogMessageSerializeDeserialize() + { + object serializer = new AzureDevOpsLogMessageSerializer(); + var message = new AzureDevOpsLogMessage("MyExecId", "MyInstId", "##[group]Tests: MyAssembly (net9.0)"); + + AzureDevOpsLogMessage actual = RoundTrip(serializer, message); + + Assert.AreEqual(message.ExecutionId, actual.ExecutionId); + Assert.AreEqual(message.InstanceId, actual.InstanceId); + Assert.AreEqual(message.LogText, actual.LogText); + } + + [TestMethod] + public void AzureDevOpsLogMessageSerializeDeserialize_WithNullOptionalFields() + { + object serializer = new AzureDevOpsLogMessageSerializer(); + var message = new AzureDevOpsLogMessage(null, null, "##[endgroup]"); + + AzureDevOpsLogMessage actual = RoundTrip(serializer, message); + + Assert.IsNull(actual.ExecutionId); + Assert.IsNull(actual.InstanceId); + Assert.AreEqual("##[endgroup]", actual.LogText); + } + [TestMethod] public void DiscoveredTestMessagesSerializeDeserialize() { @@ -360,6 +386,7 @@ public void SerializerIds_AreStable() [TestSessionEventFieldsId.MessagesSerializerId] = nameof(TestSessionEventFieldsId), [HandshakeMessageFieldsId.MessagesSerializerId] = nameof(HandshakeMessageFieldsId), [TestInProgressMessagesFieldsId.MessagesSerializerId] = nameof(TestInProgressMessagesFieldsId), + [AzureDevOpsLogMessageFieldsId.MessagesSerializerId] = nameof(AzureDevOpsLogMessageFieldsId), }; Assert.AreEqual(nameof(VoidResponseFieldsId), serializerIds[0]); @@ -374,6 +401,7 @@ public void SerializerIds_AreStable() Assert.AreEqual(nameof(TestSessionEventFieldsId), serializerIds[8]); Assert.AreEqual(nameof(HandshakeMessageFieldsId), serializerIds[9]); Assert.AreEqual(nameof(TestInProgressMessagesFieldsId), serializerIds[10]); + Assert.AreEqual(nameof(AzureDevOpsLogMessageFieldsId), serializerIds[11]); } // The SessionEventTypes byte values flow over IPC to dotnet test in the dotnet/sdk repository. @@ -429,6 +457,6 @@ public void ProtocolVersion_IsStable() // Indirect through a collection so the MSTest analyzer does not flag the comparison of a compile-time // constant as "always true" (MSTEST0032). string[] versions = [ProtocolConstants.SupportedVersions]; - Assert.AreEqual("1.0.0;1.1.0", versions[0]); + Assert.AreEqual("1.0.0;1.1.0;1.2.0", versions[0]); } } diff --git a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/OutputDevice/AzureDevOpsLogIssueFormatterTests.cs b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/OutputDevice/AzureDevOpsLogIssueFormatterTests.cs index 737d1153b4..085cbf1d2a 100644 --- a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/OutputDevice/AzureDevOpsLogIssueFormatterTests.cs +++ b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/OutputDevice/AzureDevOpsLogIssueFormatterTests.cs @@ -111,4 +111,37 @@ public void IsAzureDevOpsEnvironment_IgnoresUnknownOptOutValue() Assert.IsTrue(AzureDevOpsLogIssueFormatter.IsAzureDevOpsEnvironment(environment.Object)); } + + [DataRow("off")] + [DataRow("false")] + [DataRow("0")] + [TestMethod] + public void IsAzureDevOpsAgent_IgnoresOptOutAndReturnsTrueWhenTfBuildIsTrue(string optOutValue) + { + // The opt-out only governs the platform's automatic ##vso[task.logissue] emission; the explicit + // --report-azdo extension output (which is what the passthrough device forwards) must stay enabled. + var environment = new Mock(); + environment.Setup(e => e.GetEnvironmentVariable("TF_BUILD")).Returns("true"); + environment.Setup(e => e.GetEnvironmentVariable("TESTINGPLATFORM_AZDO_OUTPUT")).Returns(optOutValue); + + Assert.IsTrue(AzureDevOpsLogIssueFormatter.IsAzureDevOpsAgent(environment.Object)); + } + + [TestMethod] + public void IsAzureDevOpsAgent_ReturnsFalseWhenTfBuildIsAbsent() + { + var environment = new Mock(); + environment.Setup(e => e.GetEnvironmentVariable(It.IsAny())).Returns((string?)null); + + Assert.IsFalse(AzureDevOpsLogIssueFormatter.IsAzureDevOpsAgent(environment.Object)); + } + + [TestMethod] + public void IsAzureDevOpsAgent_ReturnsFalseWhenTfBuildIsFalse() + { + var environment = new Mock(); + environment.Setup(e => e.GetEnvironmentVariable("TF_BUILD")).Returns("false"); + + Assert.IsFalse(AzureDevOpsLogIssueFormatter.IsAzureDevOpsAgent(environment.Object)); + } } diff --git a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/OutputDevice/DotnetTestPassthroughOutputDeviceTests.cs b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/OutputDevice/DotnetTestPassthroughOutputDeviceTests.cs new file mode 100644 index 0000000000..8990396590 --- /dev/null +++ b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/OutputDevice/DotnetTestPassthroughOutputDeviceTests.cs @@ -0,0 +1,51 @@ +// 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.OutputDevice; +using Microsoft.Testing.Platform.ServerMode; + +using Moq; + +namespace Microsoft.Testing.Platform.UnitTests; + +[TestClass] +public sealed class DotnetTestPassthroughOutputDeviceTests +{ + private static readonly IOutputDeviceDataProducer Producer = Mock.Of(); + + [TestMethod] + public async Task IsEnabledAsync_ReturnsFalse_LikeNopDevice() + { + var device = new DotnetTestPassthroughOutputDevice(Mock.Of()); + Assert.IsFalse(await device.IsEnabledAsync()); + } + + [TestMethod] + public async Task DisplayAsync_WithNonMarkerData_IsSwallowedWithoutResolvingTheConnection() + { + // A strict mock fails the test if the device touches the service provider at all: non-marker + // output must be discarded as early as NopPlatformOutputDevice would, preserving the deliberate + // pipe-protocol suppression. + var serviceProvider = new Mock(MockBehavior.Strict); + var device = new DotnetTestPassthroughOutputDevice(serviceProvider.Object); + + await device.DisplayAsync(Producer, new FormattedTextOutputDeviceData("##[group]not a marker"), CancellationToken.None); + + serviceProvider.Verify(p => p.GetService(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task DisplayAsync_WithMarkerButNoConnection_IsSwallowedWithoutThrowing() + { + // The marker is recognized (so the connection is looked up), but with no dotnet test connection + // resolved there is nothing to forward to and the line is dropped. + var serviceProvider = new Mock(); + serviceProvider.Setup(p => p.GetService(typeof(IPushOnlyProtocol))).Returns(null!); + var device = new DotnetTestPassthroughOutputDevice(serviceProvider.Object); + + await device.DisplayAsync(Producer, new AzureDevOpsCommandOutputDeviceData("##[group]Tests: A (net9.0)"), CancellationToken.None); + + serviceProvider.Verify(p => p.GetService(typeof(IPushOnlyProtocol)), Times.Once); + } +}