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..5c7b53000c --- /dev/null +++ b/src/Platform/Microsoft.Testing.Platform/OutputDevice/AzureDevOpsCommandOutputDeviceData.cs @@ -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; + +/// +/// 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 ##[group], ##[endgroup] or ##vso[...]), but it also carries +/// the extension's other report output, such as the slow-test progress lines. +/// +/// +/// 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 +/// AzureDevOpsReport output is 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..e3d06494d4 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) 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() @@ -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, _cancellationTokenSource.CancellationToken).ConfigureAwait(false); + await dotnetTestPipeClient.RequestReplyAsync(discoveredTestMessages, _cancellationTokenSource.CancellationToken).ConfigureAwait(false); break; case TestResultMessages testResultMessages: - await _dotnetTestPipeClient.RequestReplyAsync(testResultMessages, _cancellationTokenSource.CancellationToken).ConfigureAwait(false); + await dotnetTestPipeClient.RequestReplyAsync(testResultMessages, _cancellationTokenSource.CancellationToken).ConfigureAwait(false); break; case FileArtifactMessages fileArtifactMessages: - await _dotnetTestPipeClient.RequestReplyAsync(fileArtifactMessages, _cancellationTokenSource.CancellationToken).ConfigureAwait(false); + await dotnetTestPipeClient.RequestReplyAsync(fileArtifactMessages, _cancellationTokenSource.CancellationToken).ConfigureAwait(false); break; case TestSessionEvent testSessionEvent: - await _dotnetTestPipeClient.RequestReplyAsync(testSessionEvent, _cancellationTokenSource.CancellationToken).ConfigureAwait(false); + 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..59195457cf --- /dev/null +++ b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/DotnetTestPipe/DotnetTestPipeAzureDevOpsForwardingTests.cs @@ -0,0 +1,190 @@ +// 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 TF_BUILD=false means the host is not on an Azure DevOps agent, so the + // AzureDevOpsReport extension produces nothing and the forwarder stays a no-op. We set TF_BUILD + // explicitly to "false" rather than leaving it unset because CI itself runs on an Azure DevOps + // agent (where TF_BUILD=true is inherited), which would otherwise make this scenario impossible to + // exercise. + FakeDotnetTestSdkResult result = await FakeDotnetTestSdk.RunAsync( + testHost, + extraArguments: "--report-azdo", + environmentVariables: new Dictionary { ["TF_BUILD"] = "false" }, + 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..6136cdafa1 100644 --- a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/DotnetTestPipe/DotnetTestPipeBaselineTests.cs +++ b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/DotnetTestPipe/DotnetTestPipeBaselineTests.cs @@ -25,14 +25,14 @@ 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..1aed36a11a 100644 --- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/AzureDevOpsArtifactUploaderTests.cs +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/AzureDevOpsArtifactUploaderTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. using Microsoft.Testing.Extensions.AzureDevOpsReport; @@ -61,7 +61,7 @@ public async Task OffMode_IsDisabledAndNoOps() await uploader.ConsumeAsync(CreateProducer(), CreateFailedTestNodeUpdateMessage(), CancellationToken.None).ConfigureAwait(false); await uploader.OnTestSessionFinishingAsync(new TestSessionContext()).ConfigureAwait(false); - Assert.IsEmpty(GetFormattedLines()); + Assert.IsEmpty(GetCommandLines()); Assert.IsEmpty(GetWarnings()); } @@ -88,9 +88,9 @@ public async Task TagsOnlyMode_EmitsOnlyBuildTags() "##vso[build.addbuildtag]has-hangdump", "##vso[build.addbuildtag]has-test-failures", }, - GetFormattedLines(), + GetCommandLines(), SequenceOrder.InAnyOrder); - Assert.DoesNotContain(line => line.Contains("artifact.upload", StringComparison.Ordinal), GetFormattedLines()); + Assert.DoesNotContain(line => line.Contains("artifact.upload", StringComparison.Ordinal), GetCommandLines()); Assert.IsEmpty(GetWarnings()); } @@ -112,7 +112,7 @@ public async Task FilesMode_EmitsOnlyArtifactLines() await uploader.ConsumeAsync(CreateProducer(), CreateFailedTestNodeUpdateMessage(), CancellationToken.None).ConfigureAwait(false); await uploader.OnTestSessionFinishingAsync(new TestSessionContext()).ConfigureAwait(false); - string[] lines = GetFormattedLines(); + string[] lines = GetCommandLines(); Assert.HasCount(2, lines); Assert.IsTrue(lines.All(line => line.StartsWith("##vso[artifact.upload containerfolder=Artifacts;artifactname=Artifacts]", StringComparison.Ordinal))); Assert.DoesNotContain(line => line.Contains("build.addbuildtag", StringComparison.Ordinal), lines); @@ -137,7 +137,7 @@ public async Task FilesMode_SkipsArtifactsOutsideResultsDirectory() Assert.AreSequenceEqual( new[] { $"##vso[artifact.upload containerfolder=Artifacts;artifactname=Artifacts]{InResults("inside.trx")}" }, - GetFormattedLines()); + GetCommandLines()); } [TestMethod] @@ -166,7 +166,7 @@ public async Task AllMode_EmitsBuildTagsAndArtifactLines() "##vso[build.addbuildtag]has-test-failures", $"##vso[artifact.upload containerfolder=Artifacts;artifactname=Artifacts]{InResults("test.trx")}", }, - GetFormattedLines()); + GetCommandLines()); } [TestMethod] @@ -190,7 +190,7 @@ public async Task IncludeAndExcludeGlobs_AreAppliedToArtifactUploads() Assert.AreSequenceEqual( new[] { $"##vso[artifact.upload containerfolder=Artifacts;artifactname=Artifacts]{InResults("keep.trx")}" }, - GetFormattedLines()); + GetCommandLines()); } [TestMethod] @@ -214,7 +214,7 @@ public async Task ConsumedArtifacts_DetectCrashAndHangDumps() "##vso[build.addbuildtag]has-crashdump", "##vso[build.addbuildtag]has-hangdump", }, - GetFormattedLines(), + GetCommandLines(), SequenceOrder.InAnyOrder); } @@ -232,7 +232,7 @@ public async Task NonDumpProducer_DoesNotEmitDumpTags() await uploader.ConsumeAsync(CreateProducer("GenericProducer", "Generic"), CreateFileArtifact(InResults("hang", "dump.dmp")), CancellationToken.None).ConfigureAwait(false); await uploader.OnTestSessionFinishingAsync(new TestSessionContext()).ConfigureAwait(false); - Assert.IsEmpty(GetFormattedLines()); + Assert.IsEmpty(GetCommandLines()); } [TestMethod] @@ -249,7 +249,7 @@ public async Task HasTestFailuresTag_IsNotEmittedWithoutFailures() await uploader.ConsumeAsync(CreateProducer(), CreatePassedTestNodeUpdateMessage(), CancellationToken.None).ConfigureAwait(false); await uploader.OnTestSessionFinishingAsync(new TestSessionContext()).ConfigureAwait(false); - Assert.DoesNotContain("##vso[build.addbuildtag]has-test-failures", GetFormattedLines()); + Assert.DoesNotContain("##vso[build.addbuildtag]has-test-failures", GetCommandLines()); } [TestMethod] @@ -273,7 +273,7 @@ public async Task MissingTfBuild_EmitsWarningAndSkipsOutput() Assert.AreSequenceEqual( new[] { "Azure DevOps artifact upload was requested, but TF_BUILD is not set to 'true'; skipping Azure DevOps artifact upload and build tags." }, GetWarnings()); - Assert.IsEmpty(GetFormattedLines()); + Assert.IsEmpty(GetCommandLines()); } [TestMethod] @@ -291,7 +291,7 @@ public async Task EmptyTestResultsDirectory_DoesNotEmitArtifactLines() await uploader.OnTestSessionStartingAsync(new TestSessionContext()).ConfigureAwait(false); await uploader.OnTestSessionFinishingAsync(new TestSessionContext()).ConfigureAwait(false); - Assert.IsEmpty(GetFormattedLines()); + Assert.IsEmpty(GetCommandLines()); Assert.IsEmpty(GetWarnings()); } @@ -309,7 +309,7 @@ public async Task WhitespaceTestResultsDirectory_DoesNotEmitArtifactLines() await uploader.OnTestSessionStartingAsync(new TestSessionContext()).ConfigureAwait(false); await uploader.OnTestSessionFinishingAsync(new TestSessionContext()).ConfigureAwait(false); - Assert.IsEmpty(GetFormattedLines()); + Assert.IsEmpty(GetCommandLines()); Assert.IsEmpty(GetWarnings()); } @@ -333,7 +333,7 @@ public async Task ArtifactUploadPaths_AreEscaped() Assert.AreSequenceEqual( new[] { $"##vso[artifact.upload containerfolder=Artifacts;artifactname=Artifacts]{escapedSpecialPath}" }, - GetFormattedLines()); + GetCommandLines()); } [DataRow(AzureDevOpsCommandLineOptions.AzureDevOpsUploadArtifactInclude, @"C:\absolute\*.trx")] @@ -427,8 +427,8 @@ private static string InResults(params string[] segments) private static string OutsideResults(params string[] segments) => segments.Aggregate(OutsideResultsDirectory, Path.Combine); - private string[] GetFormattedLines() - => [.. _outputData.OfType().Select(output => output.Text)]; + private string[] GetCommandLines() + => [.. _outputData.OfType().Select(data => data.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..98fe400769 100644 --- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/AzureDevOpsLogGroupReporterTests.cs +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/AzureDevOpsLogGroupReporterTests.cs @@ -65,7 +65,7 @@ public async Task SessionStarting_EmitsGroupHeaderWithAssemblyAndTfmAsync() await reporter.OnTestSessionStartingAsync(new TestSessionContext()); - string[] lines = GetFormattedLines(); + string[] lines = GetCommandLines(); Assert.HasCount(1, lines); string headerPrefix = AzureDevOpsResources.LogGroupHeader.Replace("{0}", string.Empty); Assert.StartsWith($"##[group]{headerPrefix}MyAssembly (", lines[0]); @@ -79,7 +79,7 @@ public async Task SessionFinishing_EmitsEndGroup_WhenGroupWasOpenedAsync() await reporter.OnTestSessionStartingAsync(new TestSessionContext()); await reporter.OnTestSessionFinishingAsync(new TestSessionContext()); - string[] lines = GetFormattedLines(); + string[] lines = GetCommandLines(); Assert.HasCount(2, lines); Assert.StartsWith("##[group]", lines[0]); Assert.AreEqual("##[endgroup]", lines[1]); @@ -92,7 +92,7 @@ public async Task SessionFinishing_DoesNothing_WhenGroupWasNeverOpenedAsync() await reporter.OnTestSessionFinishingAsync(new TestSessionContext()); - Assert.IsEmpty(GetFormattedLines()); + Assert.IsEmpty(GetCommandLines()); } [TestMethod] @@ -108,7 +108,7 @@ await reporter.ConsumeAsync( CreateTestNodeUpdateMessage("t1", new PassedTestNodeStateProperty()), CancellationToken.None); - Assert.IsEmpty(GetFormattedLines()); + Assert.IsEmpty(GetCommandLines()); } private static TestNodeUpdateMessage CreateTestNodeUpdateMessage(string uid, TestNodeStateProperty state) @@ -136,8 +136,8 @@ private AzureDevOpsLogGroupReporter CreateReporter(bool enabled, bool tfBuild) _loggerFactoryMock.Object); } - private string[] GetFormattedLines() - => [.. _outputData.OfType().Select(output => output.Text)]; + private string[] GetCommandLines() + => [.. _outputData.OfType().Select(data => data.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..6855f10b17 100644 --- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/AzureDevOpsSummaryReporterTests.cs +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/AzureDevOpsSummaryReporterTests.cs @@ -63,7 +63,7 @@ public async Task SessionFinishing_DoesNothingAndEmitsWarning_WhenTfBuildNotSetA await reporter.ConsumeAsync(CreateProducer(), CreatePassed("t1"), CancellationToken.None).ConfigureAwait(false); await reporter.OnTestSessionFinishingAsync(new TestSessionContext()).ConfigureAwait(false); - Assert.IsEmpty(GetFormattedLines()); + Assert.IsEmpty(GetCommandLines()); } [TestMethod] @@ -157,7 +157,7 @@ public async Task SessionFinishing_WritesSummaryFileAndEmitsUploadSummaryCommand string written = System.Text.Encoding.UTF8.GetString(memoryStream.ToArray()); Assert.Contains("MyAssembly", written); - string[] lines = GetFormattedLines(); + string[] lines = GetCommandLines(); Assert.HasCount(1, lines); Assert.StartsWith("##vso[task.uploadsummary]", lines[0]); Assert.Contains("azdo-summary-", lines[0]); @@ -198,8 +198,8 @@ private static TestNodeUpdateMessage Create(string uid, TestNodeStateProperty st private static IDataProducer CreateProducer() => new TestProducer(); - private string[] GetFormattedLines() - => [.. _outputData.OfType().Select(output => output.Text)]; + private string[] GetCommandLines() + => [.. _outputData.OfType().Select(data => data.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); + } +}