diff --git a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/DotnetTestPipe/DotnetTestPipeBaselineTests.cs b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/DotnetTestPipe/DotnetTestPipeBaselineTests.cs
new file mode 100644
index 0000000000..5692d5d408
--- /dev/null
+++ b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/DotnetTestPipe/DotnetTestPipeBaselineTests.cs
@@ -0,0 +1,177 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using System.Text.RegularExpressions;
+
+namespace Microsoft.Testing.Platform.Acceptance.IntegrationTests.DotnetTestPipe;
+
+///
+/// Baseline tests for the --server dotnettestcli pipe protocol that lock down today's
+/// behavior so subsequent phases (quiet output device under pipe mode, protocol 1.0.1, live log
+/// and stream forwarding) can be reviewed as targeted diffs against these assertions.
+///
+/// Tracks dotnet/sdk#51615 and the
+/// related microsoft/testfx#7161.
+///
+///
+[TestClass]
+public class DotnetTestPipeBaselineTests : AcceptanceTestBase
+{
+ private const string AssetName = "DotnetTestPipeBaselineTest";
+
+ private static readonly Regex BannerRegex = new(@"Microsoft\.Testing\.Platform v.+ \[.+\]", RegexOptions.Compiled);
+
+ public TestContext TestContext { get; set; } = null!;
+
+ [TestMethod]
+ public async Task DotnetTestPipe_TestAppAdvertisesAndNegotiatesProtocolV100_Today()
+ {
+ var testHost = TestInfrastructure.TestHost.LocateFrom(
+ AssetFixture.TargetAssetPath, AssetName, TargetFrameworks.NetCurrent);
+
+ FakeDotnetTestSdkResult result = await FakeDotnetTestSdk.RunAsync(
+ testHost, cancellationToken: TestContext.CancellationToken);
+
+ Assert.IsNotNull(result.ReceivedHandshake, "Test app never sent a handshake message.");
+ Assert.IsNotNull(result.SentHandshakeReply, "Fake SDK never sent a handshake reply — check that the handshake frame was received and decoded.");
+
+ Assert.IsTrue(
+ result.ReceivedHandshake.TryGetValue(DotnetTestPipeProtocol.HandshakeProperties.SupportedProtocolVersions, out string? appVersions),
+ "Handshake from test app is missing SupportedProtocolVersions.");
+ Assert.AreEqual(
+ FakeDotnetTestSdk.DefaultSupportedProtocolVersions,
+ appVersions,
+ "BASELINE: today the test app advertises only protocol 1.0.0. When this assertion " +
+ "starts failing, it means the testfx side has been bumped to 1.0.1+ and the version " +
+ "negotiation tests should take over (Phase 2 of dotnet/sdk#51615).");
+
+ Assert.AreEqual(
+ FakeDotnetTestSdk.DefaultSupportedProtocolVersions,
+ result.NegotiatedProtocolVersion,
+ "Fake SDK should have selected '1.0.0' from the test app's advertised list.");
+ }
+
+ [TestMethod]
+ public async Task DotnetTestPipe_EmitsTestSessionStartAndEnd()
+ {
+ var testHost = TestInfrastructure.TestHost.LocateFrom(
+ AssetFixture.TargetAssetPath, AssetName, TargetFrameworks.NetCurrent);
+
+ FakeDotnetTestSdkResult result = await FakeDotnetTestSdk.RunAsync(
+ testHost, cancellationToken: TestContext.CancellationToken);
+
+ byte[] sessionEventTypes =
+ [
+ .. result.MessagesWithSerializerId(DotnetTestPipeProtocol.SerializerIds.TestSessionEvent)
+ .Select(m => DotnetTestPipeProtocol.DecodeTestSessionEventBody(m.Body).SessionType)
+ .Where(t => t.HasValue)
+ .Select(t => t!.Value)
+ ];
+
+ Assert.HasCount(2, sessionEventTypes, $"Expected exactly two session events; got [{string.Join(", ", sessionEventTypes)}].");
+ Assert.AreEqual(DotnetTestPipeProtocol.SessionEventTypes.TestSessionStart, sessionEventTypes[0]);
+ Assert.AreEqual(DotnetTestPipeProtocol.SessionEventTypes.TestSessionEnd, sessionEventTypes[1]);
+ }
+
+ ///
+ /// BASELINE: under --server dotnettestcli the test app today emits nothing to stdout/stderr
+ /// for a passing run (the MTP banner is already silenced via _isServerMode in
+ /// TerminalOutputDevice, and our DummyTestFramework writes nothing of its own).
+ /// This test pins that contract so future changes can't accidentally start leaking output that
+ /// would compete with what the SDK is also rendering (the original symptom behind
+ /// dotnet/sdk#51615 and microsoft/testfx#7161). When this test fails, evaluate whether the
+ /// added stdout/stderr writes should instead flow through the pipe as structured messages.
+ ///
+ [TestMethod]
+ public async Task DotnetTestPipe_ChildEmitsNoStdoutOrStderrForPassingRun_Baseline()
+ {
+ var testHost = TestInfrastructure.TestHost.LocateFrom(
+ AssetFixture.TargetAssetPath, AssetName, TargetFrameworks.NetCurrent);
+
+ FakeDotnetTestSdkResult result = await FakeDotnetTestSdk.RunAsync(
+ testHost, cancellationToken: TestContext.CancellationToken);
+
+ Assert.DoesNotMatchRegex(
+ BannerRegex,
+ result.TestHostResult.StandardOutput,
+ $"BASELINE: under --server dotnettestcli the test app already silences its banner. " +
+ $"If this fails, something started re-emitting the banner under pipe mode.{Environment.NewLine}" +
+ $"Captured stdout:{Environment.NewLine}{result.TestHostResult.StandardOutput}");
+
+ Assert.AreEqual(
+ string.Empty,
+ result.TestHostResult.StandardOutput.Trim(),
+ $"BASELINE: today the test app emits nothing to stdout under pipe mode for a no-op " +
+ $"DummyTestFramework run. New text leaking here likely belongs in a pipe message " +
+ $"(Phase 2 of dotnet/sdk#51615 adds a LogMessage channel for this).{Environment.NewLine}" +
+ $"Captured stdout:{Environment.NewLine}{result.TestHostResult.StandardOutput}");
+
+ Assert.AreEqual(
+ string.Empty,
+ result.TestHostResult.StandardError.Trim(),
+ $"BASELINE: today the test app emits nothing to stderr under pipe mode for a passing run.{Environment.NewLine}" +
+ $"Captured stderr:{Environment.NewLine}{result.TestHostResult.StandardError}");
+ }
+
+ public sealed class TestAssetFixture() : TestAssetFixtureBase()
+ {
+ private const string AssetCode = """
+#file DotnetTestPipeBaselineTest.csproj
+
+
+ $TargetFrameworks$
+ enable
+ enable
+ Exe
+ true
+ preview
+
+
+
+
+
+
+
+#file Program.cs
+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());
+ 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,
+ AssetCode
+ .PatchTargetFrameworks(TargetFrameworks.NetCurrent)
+ .PatchCodeWithReplace("$MicrosoftTestingPlatformVersion$", MicrosoftTestingPlatformVersion));
+ }
+}
diff --git a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/DotnetTestPipe/DotnetTestPipeProtocol.cs b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/DotnetTestPipe/DotnetTestPipeProtocol.cs
new file mode 100644
index 0000000000..f6ca6151e0
--- /dev/null
+++ b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/DotnetTestPipe/DotnetTestPipeProtocol.cs
@@ -0,0 +1,301 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using System.Buffers;
+using System.IO.Pipes;
+using System.Text;
+
+namespace Microsoft.Testing.Platform.Acceptance.IntegrationTests.DotnetTestPipe;
+
+///
+/// Black-box reader/writer for the --server dotnettestcli --dotnet-test-pipe wire
+/// protocol. Implements the framing and the serializer IDs / field IDs documented in
+/// src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/ObjectFieldIds.cs
+/// without referencing any of the testfx internal types (which are [Embedded] and
+/// therefore invisible to this assembly).
+///
+/// Treating this as a contract reader makes the resulting tests true black-box assertions on the
+/// wire format: any silent change to the message shape on the testfx side will be caught here.
+///
+///
+internal static class DotnetTestPipeProtocol
+{
+ ///
+ /// Frame layout (matches NamedPipeBase):
+ ///
+ /// int32 totalPayloadLength // = sizeof(serializerId) + bodyLength
+ /// int32 serializerId
+ /// byte[] body
+ ///
+ ///
+ public static class SerializerIds
+ {
+ public const int VoidResponse = 0;
+ public const int TestHostCompletedRequest = 1;
+ public const int TestHostProcessPIDRequest = 2;
+ public const int CommandLineOptionMessages = 3;
+ public const int DiscoveredTestMessages = 5;
+ public const int TestResultMessages = 6;
+ public const int FileArtifactMessages = 7;
+ public const int TestSessionEvent = 8;
+ public const int HandshakeMessage = 9;
+ public const int TestInProgressMessages = 10;
+ }
+
+ public static class HandshakeProperties
+ {
+ public const byte PID = 0;
+ public const byte Architecture = 1;
+ public const byte Framework = 2;
+ public const byte OS = 3;
+ public const byte SupportedProtocolVersions = 4;
+ public const byte HostType = 5;
+ public const byte ModulePath = 6;
+ public const byte ExecutionId = 7;
+ public const byte InstanceId = 8;
+ public const byte IsIDE = 9;
+ public const byte ExecutionMode = 10;
+ }
+
+ public static class SessionEventTypes
+ {
+ public const byte TestSessionStart = 0;
+ public const byte TestSessionEnd = 1;
+ }
+
+ public static class TestSessionEventFields
+ {
+ public const ushort SessionType = 1;
+ public const ushort SessionUid = 2;
+ public const ushort ExecutionId = 3;
+ }
+
+ ///
+ /// Computes the OS-level named pipe name from a friendly identifier. Mirrors
+ /// NamedPipeServer.GetPipeName in testfx.
+ ///
+ public static string GetPipeName(string name)
+ {
+ bool isUnix = Path.DirectorySeparatorChar == '/';
+ return isUnix
+ ? Path.Combine("/tmp", name)
+ : $"testingplatform.pipe.{name.Replace('\\', '.')}";
+ }
+
+ ///
+ /// Reads exactly one framed message from the pipe. Returns on EOF
+ /// (peer disconnected cleanly).
+ ///
+ public static async Task ReadFrameAsync(PipeStream stream, CancellationToken cancellationToken)
+ {
+ byte[] header = ArrayPool.Shared.Rent(sizeof(int));
+ try
+ {
+ if (!await TryReadExactlyAsync(stream, header.AsMemory(0, sizeof(int)), cancellationToken).ConfigureAwait(false))
+ {
+ return null;
+ }
+
+ int totalPayloadLength = BitConverter.ToInt32(header, 0);
+
+ byte[] payload = new byte[totalPayloadLength];
+ if (!await TryReadExactlyAsync(stream, payload, cancellationToken).ConfigureAwait(false))
+ {
+ return null;
+ }
+
+ int serializerId = BitConverter.ToInt32(payload, 0);
+ byte[] body = new byte[totalPayloadLength - sizeof(int)];
+ Array.Copy(payload, sizeof(int), body, 0, body.Length);
+
+ return new RawMessage(serializerId, body);
+ }
+ finally
+ {
+ ArrayPool.Shared.Return(header);
+ }
+ }
+
+ ///
+ /// Writes one framed message to the pipe.
+ ///
+ public static async Task WriteFrameAsync(PipeStream stream, int serializerId, byte[] body, CancellationToken cancellationToken)
+ {
+ int totalPayloadLength = sizeof(int) + body.Length;
+ byte[] header = BitConverter.GetBytes(totalPayloadLength);
+ byte[] id = BitConverter.GetBytes(serializerId);
+
+ await stream.WriteAsync(header.AsMemory(0, sizeof(int)), cancellationToken).ConfigureAwait(false);
+ await stream.WriteAsync(id.AsMemory(0, sizeof(int)), cancellationToken).ConfigureAwait(false);
+ await stream.WriteAsync(body.AsMemory(0, body.Length), cancellationToken).ConfigureAwait(false);
+ await stream.FlushAsync(cancellationToken).ConfigureAwait(false);
+
+ if (OperatingSystem.IsWindows() && stream is NamedPipeServerStream serverStream)
+ {
+ serverStream.WaitForPipeDrain();
+ }
+ }
+
+ ///
+ /// Decodes the body of a frame into its property
+ /// dictionary. Format: ushort fieldCount; (byte key, length-prefixed UTF-8 value) * fieldCount.
+ ///
+ public static Dictionary DecodeHandshakeBody(byte[] body)
+ {
+ Dictionary result = [];
+ using MemoryStream stream = new(body, writable: false);
+
+ ushort count = ReadUShort(stream);
+ for (int i = 0; i < count; i++)
+ {
+ byte key = (byte)stream.ReadByte();
+ string value = ReadLengthPrefixedString(stream);
+ result.Add(key, value);
+ }
+
+ return result;
+ }
+
+ ///
+ /// Encodes the body of a frame from a property
+ /// dictionary.
+ ///
+ public static byte[] EncodeHandshakeBody(Dictionary properties)
+ {
+ using MemoryStream stream = new();
+ WriteUShort(stream, (ushort)properties.Count);
+ foreach (KeyValuePair kvp in properties)
+ {
+ stream.WriteByte(kvp.Key);
+ WriteLengthPrefixedString(stream, kvp.Value);
+ }
+
+ return stream.ToArray();
+ }
+
+ ///
+ /// Decodes the body of a frame.
+ /// Format: ushort fieldCount; (ushort fieldId, int fieldSize, payload)*fieldCount
+ /// where payload shape is determined by fieldId. Returns null for fields that are absent.
+ ///
+ public static (byte? SessionType, string? SessionUid, string? ExecutionId) DecodeTestSessionEventBody(byte[] body)
+ {
+ byte? sessionType = null;
+ string? sessionUid = null;
+ string? executionId = 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 TestSessionEventFields.SessionType:
+ sessionType = (byte)stream.ReadByte();
+
+ // SessionType is a single byte today, but advance past any extra bytes the
+ // wire format may carry so subsequent fields stay aligned.
+ if (fieldSize > 1)
+ {
+ stream.Seek(fieldSize - 1, SeekOrigin.Current);
+ }
+
+ break;
+ case TestSessionEventFields.SessionUid:
+ sessionUid = ReadFixedSizeString(stream, fieldSize);
+ break;
+ case TestSessionEventFields.ExecutionId:
+ executionId = ReadFixedSizeString(stream, fieldSize);
+ break;
+ default:
+ // Unknown field id: skip forward.
+ stream.Seek(fieldSize, SeekOrigin.Current);
+ break;
+ }
+ }
+
+ return (sessionType, sessionUid, executionId);
+ }
+
+ private static async Task TryReadExactlyAsync(Stream stream, Memory buffer, CancellationToken cancellationToken)
+ {
+ int totalRead = 0;
+ while (totalRead < buffer.Length)
+ {
+ int read = await stream.ReadAsync(buffer.Slice(totalRead), cancellationToken).ConfigureAwait(false);
+ if (read == 0)
+ {
+ return false;
+ }
+
+ totalRead += read;
+ }
+
+ return true;
+ }
+
+ private static ushort ReadUShort(Stream stream)
+ {
+ Span bytes = stackalloc byte[sizeof(ushort)];
+ stream.ReadExactly(bytes);
+ return BitConverter.ToUInt16(bytes);
+ }
+
+ private static int ReadInt(Stream stream)
+ {
+ Span bytes = stackalloc byte[sizeof(int)];
+ stream.ReadExactly(bytes);
+ return BitConverter.ToInt32(bytes);
+ }
+
+ private static void WriteUShort(Stream stream, ushort value)
+ {
+ Span bytes = stackalloc byte[sizeof(ushort)];
+ BitConverter.TryWriteBytes(bytes, value);
+ stream.Write(bytes);
+ }
+
+ private static string ReadLengthPrefixedString(Stream stream)
+ {
+ int length = ReadInt(stream);
+ return ReadFixedSizeString(stream, length);
+ }
+
+ private static string ReadFixedSizeString(Stream stream, int length)
+ {
+ byte[] buffer = ArrayPool.Shared.Rent(length);
+ try
+ {
+ stream.ReadExactly(buffer, 0, length);
+ return Encoding.UTF8.GetString(buffer, 0, length);
+ }
+ finally
+ {
+ ArrayPool.Shared.Return(buffer);
+ }
+ }
+
+ private static void WriteLengthPrefixedString(Stream stream, string value)
+ {
+ int byteCount = Encoding.UTF8.GetByteCount(value);
+ byte[] header = BitConverter.GetBytes(byteCount);
+ stream.Write(header, 0, header.Length);
+
+ byte[] buffer = ArrayPool.Shared.Rent(byteCount);
+ try
+ {
+ int written = Encoding.UTF8.GetBytes(value, buffer);
+ stream.Write(buffer, 0, written);
+ }
+ finally
+ {
+ ArrayPool.Shared.Return(buffer);
+ }
+ }
+}
+
+/// A raw decoded pipe frame (serializer id + body bytes, no further decoding).
+internal sealed record RawMessage(int SerializerId, byte[] Body);
diff --git a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/DotnetTestPipe/FakeDotnetTestSdk.cs b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/DotnetTestPipe/FakeDotnetTestSdk.cs
new file mode 100644
index 0000000000..fb98303e30
--- /dev/null
+++ b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/DotnetTestPipe/FakeDotnetTestSdk.cs
@@ -0,0 +1,162 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using System.Globalization;
+using System.IO.Pipes;
+using System.Runtime.InteropServices;
+
+namespace Microsoft.Testing.Platform.Acceptance.IntegrationTests.DotnetTestPipe;
+
+///
+/// In-process stand-in for the dotnet test SDK side of the
+/// --server dotnettestcli --dotnet-test-pipe protocol. Listens on a named pipe, performs
+/// the same handshake negotiation the real SDK uses today, captures every message the test app
+/// sends, and returns a .
+///
+/// Built on a hand-rolled wire reader () so that this harness
+/// is independent of testfx internal types and exercises the wire format itself.
+///
+///
+internal static class FakeDotnetTestSdk
+{
+ /// Default fake-SDK advertised protocol version. Mirrors today's
+ /// ProtocolConstants.SupportedVersions on the SDK side.
+ public const string DefaultSupportedProtocolVersions = "1.0.0";
+
+ ///
+ /// Spins up a fake SDK pipe server, runs against it, and returns
+ /// everything observed during the run.
+ ///
+ public static async Task RunAsync(
+ global::Microsoft.Testing.TestInfrastructure.TestHost testHost,
+ string? extraArguments = null,
+ Dictionary? environmentVariables = null,
+ string supportedProtocolVersions = DefaultSupportedProtocolVersions,
+ bool isIde = false,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(testHost);
+
+ string pipeId = Guid.NewGuid().ToString("N");
+ string osPipeName = DotnetTestPipeProtocol.GetPipeName(pipeId);
+
+ // CurrentUserOnly hardens the pipe ACL so only the current user can connect. It is
+ // available here because this harness only targets .NET (Core) ($(NetCurrent)).
+ PipeOptions options = PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly;
+
+ // .NET Framework's NamedPipeServerStream takes the pipe name without leading \\.\pipe\
+ // — Windows adds it automatically. On Unix, .NET 6+ uses Unix domain sockets at the given
+ // path (matching what NamedPipeServer.GetPipeName produces with Path.Combine("/tmp", name)).
+ using NamedPipeServerStream stream = new(osPipeName, PipeDirection.InOut, maxNumberOfServerInstances: 1, PipeTransmissionMode.Byte, options);
+
+ List received = [];
+ Dictionary? receivedHandshake = null;
+ Dictionary? sentHandshakeReply = null;
+ string? negotiatedVersion = null;
+
+ string pipeArgs = $"--server dotnettestcli --dotnet-test-pipe {osPipeName}";
+ string finalArgs = extraArguments is null ? pipeArgs : $"{pipeArgs} {extraArguments}";
+ Task hostRun = testHost.ExecuteAsync(finalArgs, environmentVariables, cancellationToken: cancellationToken);
+
+ // Wait for the test app to connect.
+ await stream.WaitForConnectionAsync(cancellationToken).ConfigureAwait(false);
+
+ // Read frames until the peer disconnects.
+ while (true)
+ {
+ RawMessage? frame = await DotnetTestPipeProtocol.ReadFrameAsync(stream, cancellationToken).ConfigureAwait(false);
+ if (frame is null)
+ {
+ break;
+ }
+
+ received.Add(frame);
+
+ if (frame.SerializerId == DotnetTestPipeProtocol.SerializerIds.HandshakeMessage)
+ {
+ receivedHandshake = DotnetTestPipeProtocol.DecodeHandshakeBody(frame.Body);
+ string selected = SelectHighestMutuallySupportedVersion(receivedHandshake, supportedProtocolVersions);
+ negotiatedVersion = selected;
+ sentHandshakeReply = BuildSdkHandshakeReply(selected, isIde);
+ byte[] replyBody = DotnetTestPipeProtocol.EncodeHandshakeBody(sentHandshakeReply);
+ await DotnetTestPipeProtocol.WriteFrameAsync(stream, DotnetTestPipeProtocol.SerializerIds.HandshakeMessage, replyBody, cancellationToken).ConfigureAwait(false);
+ }
+ else
+ {
+ // For every other request the SDK replies with a VoidResponse (empty body).
+ await DotnetTestPipeProtocol.WriteFrameAsync(stream, DotnetTestPipeProtocol.SerializerIds.VoidResponse, body: [], cancellationToken).ConfigureAwait(false);
+ }
+ }
+
+ TestHostResult hostResult = await hostRun.ConfigureAwait(false);
+
+ return new FakeDotnetTestSdkResult(
+ hostResult,
+ received,
+ receivedHandshake,
+ sentHandshakeReply,
+ negotiatedVersion);
+ }
+
+ ///
+ /// Mirrors TestApplication.GetSupportedProtocolVersion on the SDK side: takes the
+ /// semicolon-separated list the test app advertised and returns the highest version that is
+ /// also in . Returns if none
+ /// match.
+ ///
+ private static string SelectHighestMutuallySupportedVersion(Dictionary handshakeProperties, string sdkSupportedVersions)
+ {
+ if (!handshakeProperties.TryGetValue(DotnetTestPipeProtocol.HandshakeProperties.SupportedProtocolVersions, out string? appVersions)
+ || string.IsNullOrWhiteSpace(appVersions))
+ {
+ return string.Empty;
+ }
+
+ HashSet sdkSet = new(
+ sdkSupportedVersions.Split(';', StringSplitOptions.RemoveEmptyEntries).Select(static raw => raw.Trim()),
+ StringComparer.Ordinal);
+
+ string? best = null;
+ Version? bestParsed = null;
+ foreach (string candidate in appVersions.Split(';', StringSplitOptions.RemoveEmptyEntries).Select(static raw => raw.Trim()))
+ {
+ if (!sdkSet.Contains(candidate))
+ {
+ continue;
+ }
+
+ if (!Version.TryParse(candidate, out Version? parsed))
+ {
+ best ??= candidate;
+ continue;
+ }
+
+ if (bestParsed is null || parsed > bestParsed)
+ {
+ best = candidate;
+ bestParsed = parsed;
+ }
+ }
+
+ return best ?? string.Empty;
+ }
+
+ private static Dictionary BuildSdkHandshakeReply(string selectedVersion, bool isIde)
+ {
+ Dictionary properties = new(capacity: 6)
+ {
+ { DotnetTestPipeProtocol.HandshakeProperties.PID, Environment.ProcessId.ToString(CultureInfo.InvariantCulture) },
+ { DotnetTestPipeProtocol.HandshakeProperties.Architecture, RuntimeInformation.ProcessArchitecture.ToString() },
+ { DotnetTestPipeProtocol.HandshakeProperties.Framework, RuntimeInformation.FrameworkDescription },
+ { DotnetTestPipeProtocol.HandshakeProperties.OS, RuntimeInformation.OSDescription },
+ { DotnetTestPipeProtocol.HandshakeProperties.SupportedProtocolVersions, selectedVersion },
+ };
+
+ if (isIde)
+ {
+ properties.Add(DotnetTestPipeProtocol.HandshakeProperties.IsIDE, bool.TrueString);
+ }
+
+ return properties;
+ }
+}
diff --git a/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/DotnetTestPipe/FakeDotnetTestSdkResult.cs b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/DotnetTestPipe/FakeDotnetTestSdkResult.cs
new file mode 100644
index 0000000000..f3df515849
--- /dev/null
+++ b/test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/DotnetTestPipe/FakeDotnetTestSdkResult.cs
@@ -0,0 +1,47 @@
+// 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;
+
+///
+/// Captures everything the fake SDK observed during a single test-app run driven through the
+/// --server dotnettestcli --dotnet-test-pipe protocol: the raw frames received on the
+/// named pipe, the SDK-selected handshake reply, and the process-level .
+/// Used by baseline tests to assert current behavior before any source change.
+///
+internal sealed class FakeDotnetTestSdkResult
+{
+ public FakeDotnetTestSdkResult(
+ TestHostResult testHostResult,
+ IReadOnlyList receivedMessages,
+ Dictionary? receivedHandshake,
+ Dictionary? sentHandshakeReply,
+ string? negotiatedProtocolVersion)
+ {
+ TestHostResult = testHostResult;
+ ReceivedMessages = receivedMessages;
+ ReceivedHandshake = receivedHandshake;
+ SentHandshakeReply = sentHandshakeReply;
+ NegotiatedProtocolVersion = negotiatedProtocolVersion;
+ }
+
+ /// The process-level result: exit code, captured stdout, captured stderr.
+ public TestHostResult TestHostResult { get; }
+
+ /// All raw frames the test app sent over the pipe, in arrival order.
+ public IReadOnlyList ReceivedMessages { get; }
+
+ /// The decoded handshake properties the test app sent (advertising its supported
+ /// protocol versions, PID, architecture, framework, OS, etc.).
+ public Dictionary? ReceivedHandshake { get; }
+
+ /// The handshake the fake SDK replied with (carrying the selected protocol version).
+ public Dictionary? SentHandshakeReply { get; }
+
+ /// The version the fake SDK selected from the test app's advertised list, or null/empty if none.
+ public string? NegotiatedProtocolVersion { get; }
+
+ /// Returns all frames whose serializer ID matches .
+ public IEnumerable MessagesWithSerializerId(int serializerId)
+ => ReceivedMessages.Where(m => m.SerializerId == serializerId);
+}