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);
+ }
+}