diff --git a/src/Platform/Microsoft.Testing.Platform.Internal.DotnetTest/Microsoft.Testing.Platform.Internal.DotnetTest.csproj b/src/Platform/Microsoft.Testing.Platform.Internal.DotnetTest/Microsoft.Testing.Platform.Internal.DotnetTest.csproj index 21338a9bf6..a1f34feb8f 100644 --- a/src/Platform/Microsoft.Testing.Platform.Internal.DotnetTest/Microsoft.Testing.Platform.Internal.DotnetTest.csproj +++ b/src/Platform/Microsoft.Testing.Platform.Internal.DotnetTest/Microsoft.Testing.Platform.Internal.DotnetTest.csproj @@ -15,12 +15,9 @@ shared source files as contentFiles/cs/any so a consumer (the dotnet/sdk 'dotnet test' implementation) compiles them into its OWN assembly, keeping a single source of truth instead of hand-copying. - Internal, source-only package for the 'dotnet test' <-> Microsoft.Testing.Platform integration. It ships - shared source files as contentFiles/cs/any so a consumer (the dotnet/sdk 'dotnet test' implementation) - compiles them into its OWN assembly, keeping a single source of truth instead of hand-copying. - - It hosts (a) the named-pipe wire contract (ObjectFieldIds.cs + Constants.cs) and (b) the terminal reporter - (reporter + rendering + state + the small platform abstractions it needs) together with its localized + It hosts (a) the whole named-pipe protocol source - the ObjectFieldIds.cs + Constants.cs wire contract plus the + message models + serializers for ids 0-12, the serializer registry and base infrastructure - and (b) the terminal + reporter (reporter + rendering + state + the small platform abstractions it needs) together with its localized resources (TerminalResources.resx + xlf, wired up by the build-extension props this package ships - see build/). The name is deliberately generic and carries 'Internal' so it is clearly NOT a public API / client library (see also issue #5667, closed pending a real use case for a public MTP client). diff --git a/src/Platform/Microsoft.Testing.Platform.Internal.DotnetTest/PACKAGE.md b/src/Platform/Microsoft.Testing.Platform.Internal.DotnetTest/PACKAGE.md index 13903818fc..69dab06ff5 100644 --- a/src/Platform/Microsoft.Testing.Platform.Internal.DotnetTest/PACKAGE.md +++ b/src/Platform/Microsoft.Testing.Platform.Internal.DotnetTest/PACKAGE.md @@ -18,8 +18,14 @@ break). The package ships shared source as `contentFiles/cs/any` with `BuildAction=Compile`, so the consumer compiles it into its **own** assembly and the `internal` types are visible without any `InternalsVisibleTo` plumbing: -- **Wire contract** (`contentFiles/cs/any/DotnetTestProtocol/`): `ObjectFieldIds.cs` (serializer/field ids) and - `Constants.cs` (handshake property names, execution modes, session-event types, test states, protocol version). +- **`dotnet test` named-pipe protocol** (`contentFiles/cs/any/DotnetTestProtocol/`): the whole serializer stack as a + single source of truth — `ObjectFieldIds.cs` (serializer/field ids) and `Constants.cs` (handshake property names, + execution modes, session-event types, test states, protocol version), the message **models** and **serializers** + for serializer ids 0–12 (including `AzureDevOpsLogMessage` = 11 and `DisplayMessage` = 12), the serializer registry + (`NamedPipeBase` + `RegisterSerializers`), the serializer base infrastructure (`BaseSerializer`, + `NamedPipeSerializer`) and the `INamedPipeSerializer`/`IRequest`/`IResponse` interfaces. The named-pipe **transport** + (`NamedPipeServer`/`NamedPipeClient`/framing) is intentionally **not** shared — it differs per repo — so a consumer + keeps its own transport and reuses the shared registry + serializers. - **Terminal reporter** (`contentFiles/cs/any/TerminalReporter/`): the reporter + rendering + state types and the small platform abstractions they need (`IConsole`/`IStopwatch`/`IColor`/`System*`, `RoslynString`, `ApplicationStateGuard`, `StackTraceHelper`, `TargetFrameworkParser`, `TestRunSummaryHelper`). @@ -30,18 +36,25 @@ into its **own** assembly and the `internal` types are visible without any `Inte ## Consuming it (plug-in requirements) -Reference the package and the source compiles into your assembly. For the terminal reporter source the consumer -must have: +Reference the package and the source compiles into your assembly. For the terminal reporter source and the +`dotnet test` protocol serializer stack the consumer must have: - **`ImplicitUsings` enabled** — the build props supplies the extra global usings the shared source relies on - (`System.Text`, `System.Runtime.CompilerServices`, `System.Runtime.Versioning`, …) when implicit usings are on. + (`System.Text`, `System.Diagnostics`, `System.Diagnostics.CodeAnalysis`, `System.Globalization`, + `System.Runtime.CompilerServices`, `System.Runtime.Versioning`, …) when implicit usings are on. - **A `LangVersion` that supports the `field` keyword** (preview/latest). -- **The `Microsoft.CodeAnalysis.EmbeddedAttribute` polyfill** — shipped by this package as source, so it is always - available (dotnet/sdk also defines its own for its copied IPC transport; the `internal sealed partial` polyfill - merges harmlessly). +- **The `Microsoft.CodeAnalysis.EmbeddedAttribute` polyfill** — the protocol registry/infra types and the terminal + reporter types are `[Microsoft.CodeAnalysis.Embedded]`, so the consumer needs this attribute. It is shipped once by + this package as source (the `internal sealed partial` polyfill merges harmlessly if the consumer also defines one). - **XliffTasks** (only for localized satellites) — dotnet/sdk has it via Arcade. +- **Down-level polyfills (only for non-`NETCOREAPP` TFMs such as `net462`/`netstandard2.0`)** — the protocol source + uses positional records (need `System.Runtime.CompilerServices.IsExternalInit`) and nullable-reference annotations + (`System.Diagnostics.CodeAnalysis` nullable attributes). Modern net TFMs (e.g. dotnet/sdk) have these built in and + need nothing; a down-level consumer must supply the usual polyfills (as the in-repo standalone test project does by + compiling `src/Polyfills`). -The wire-contract source is zero-dependency and needs none of the above. +Only the `ObjectFieldIds`/`Constants` wire-contract subset is zero-dependency and needs none of the above; the rest of +the protocol serializer stack has the same requirements as the terminal reporter source. > ℹ️ The terminal reporter ships a couple of small abstractions that also live in Microsoft.Testing.Platform. The > internal ones (`IConsole`/`IStopwatch`/`System*`) are not exported by the platform, so they never conflict. The two @@ -53,6 +66,9 @@ The wire-contract source is zero-dependency and needs none of the above. ## Scope -The message **models** and **serializers** are not shared yet because they still depend on `TestMetadataProperty` -(`Microsoft.Testing.Platform.Extensions.Messages`) and use a different class shape than the SDK's copy; sharing them -requires decoupling/unifying first. +The `dotnet test` protocol source is decoupled so it compiles standalone in a consumer that has no reference to +Microsoft.Testing.Platform: `BaseSerializer` carries its own `Unreachable()`/`DebugAssert` helpers (no +`ApplicationStateGuard`/`RoslynDebug`); the `DiscoveredTestMessage` trait uses the platform-decoupled `TraitMessage` +wire type instead of the public `TestMetadataProperty`; and `NamedPipeBase` is registry-only (no `PipeStream`/framing). +None of this changes the wire bytes — serializer ids, field ids, field order and layout are unchanged. Only the +named-pipe **transport** (`NamedPipeServer`/`NamedPipeClient`) is kept per-repo. diff --git a/src/Platform/Microsoft.Testing.Platform/IPC/NamedPipeBase.cs b/src/Platform/Microsoft.Testing.Platform/IPC/NamedPipeBase.cs index 3440c30b64..db6db5f76a 100644 --- a/src/Platform/Microsoft.Testing.Platform/IPC/NamedPipeBase.cs +++ b/src/Platform/Microsoft.Testing.Platform/IPC/NamedPipeBase.cs @@ -1,28 +1,19 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. -#if NET -using System.Buffers; -#endif - -using System.IO.Pipes; - using Microsoft.CodeAnalysis; -#if NET -using Microsoft.Testing.Platform.Helpers; -#endif namespace Microsoft.Testing.Platform.IPC; +// Serializer registry shared over the 'dotnet test' named pipe. This is the part of the pipe base that is shared as +// source with dotnet/sdk (via RegisterSerializers.RegisterAllSerializers); it is intentionally self-contained (no +// framing/transport, no PipeStream, no platform helpers) so it compiles standalone in a consumer that has no +// reference to Microsoft.Testing.Platform. The framing/transport lives in the repo-local NamedPipeConnectionBase. [Embedded] -[SuppressMessage("Design", "CA1001:Types that own disposable fields should be disposable", Justification = "Disposal is delegated to subclasses via DisposeBuffers().")] internal abstract class NamedPipeBase { private readonly Dictionary _typeSerializer = []; private readonly Dictionary _idSerializer = []; - private readonly MemoryStream _serializationBuffer = new(); - private readonly MemoryStream _messageBuffer = new(); - private readonly byte[] _readBuffer = new byte[250000]; public void RegisterSerializer(INamedPipeSerializer namedPipeSerializer, Type type) { @@ -35,173 +26,4 @@ protected INamedPipeSerializer GetSerializer(int id) protected INamedPipeSerializer GetSerializer(Type type) => _typeSerializer[type]; - - /// - /// Serializes using , frames it with a - /// 4-byte size header and 4-byte serializer-ID prefix, writes the frame to , - /// flushes, and (on Windows) waits for the pipe to drain. - /// -#if !MTP_MSBUILD_TASKS - [UnsupportedOSPlatform("browser")] -#endif - protected async Task WriteMessageAsync(PipeStream stream, INamedPipeSerializer serializer, object message, CancellationToken cancellationToken) - { - // Serialize the message body - _serializationBuffer.Position = 0; - serializer.Serialize(message, _serializationBuffer); - - // Build the framed message: - // 4 bytes – total payload length (serializer ID + body) - // 4 bytes – serializer ID - // N bytes – serialized body - _messageBuffer.Position = 0; - int sizeOfTheWholeMessage = (int)_serializationBuffer.Position + sizeof(int); - - try - { - // Write the message size header -#if NET - byte[] bytes = ArrayPool.Shared.Rent(sizeof(int)); - try - { - if (!BitConverter.TryWriteBytes(bytes, sizeOfTheWholeMessage)) - { - // TryWriteBytes only fails if destination is too small; we rented at least sizeof(int). - throw ApplicationStateGuard.Unreachable(); - } - - await _messageBuffer.WriteAsync(bytes.AsMemory(0, sizeof(int)), cancellationToken).ConfigureAwait(false); - } - finally - { - ArrayPool.Shared.Return(bytes); - } -#else - await _messageBuffer.WriteAsync(BitConverter.GetBytes(sizeOfTheWholeMessage), 0, sizeof(int), cancellationToken).ConfigureAwait(false); -#endif - - // Write the serializer ID -#if NET - bytes = ArrayPool.Shared.Rent(sizeof(int)); - try - { - if (!BitConverter.TryWriteBytes(bytes, serializer.Id)) - { - throw ApplicationStateGuard.Unreachable(); - } - - await _messageBuffer.WriteAsync(bytes.AsMemory(0, sizeof(int)), cancellationToken).ConfigureAwait(false); - } - finally - { - ArrayPool.Shared.Return(bytes); - } -#else - await _messageBuffer.WriteAsync(BitConverter.GetBytes(serializer.Id), 0, sizeof(int), cancellationToken).ConfigureAwait(false); -#endif - - // Write the serialized payload -#if NET - await _messageBuffer.WriteAsync(_serializationBuffer.GetBuffer().AsMemory(0, (int)_serializationBuffer.Position), cancellationToken).ConfigureAwait(false); -#else - await _messageBuffer.WriteAsync(_serializationBuffer.GetBuffer(), 0, (int)_serializationBuffer.Position, cancellationToken).ConfigureAwait(false); -#endif - - // Send the framed message to the pipe stream -#if NET - await stream.WriteAsync(_messageBuffer.GetBuffer().AsMemory(0, (int)_messageBuffer.Position), cancellationToken).ConfigureAwait(false); -#else - await stream.WriteAsync(_messageBuffer.GetBuffer(), 0, (int)_messageBuffer.Position, cancellationToken).ConfigureAwait(false); -#endif - await stream.FlushAsync(cancellationToken).ConfigureAwait(false); - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - stream.WaitForPipeDrain(); - } - } - finally - { - _messageBuffer.Position = 0; - _serializationBuffer.Position = 0; - } - } - - /// - /// Reads one complete framed message from , deserializes it, and returns - /// the result. Returns when the stream reaches EOF (peer disconnected). - /// -#if !MTP_MSBUILD_TASKS - [UnsupportedOSPlatform("browser")] -#endif - protected async Task ReadNextMessageAsync(PipeStream stream, CancellationToken cancellationToken) - { - _messageBuffer.Position = 0; - - try - { - // Read the 4-byte size header. PipeStream.ReadAsync may return fewer bytes than requested - // (e.g. in byte-mode pipes), so we must loop until we've accumulated the full header or hit EOF. - int headerBytesRead = 0; - while (headerBytesRead < sizeof(int)) - { -#if NET - int n = await stream.ReadAsync(_readBuffer.AsMemory(headerBytesRead, sizeof(int) - headerBytesRead), cancellationToken).ConfigureAwait(false); -#else - int n = await stream.ReadAsync(_readBuffer, headerBytesRead, sizeof(int) - headerBytesRead, cancellationToken).ConfigureAwait(false); -#endif - if (n == 0) - { - // EOF – peer disconnected (cleanly if headerBytesRead == 0, mid-header otherwise); - // caller decides how to handle this. - return null; - } - - headerBytesRead += n; - } - - int currentMessageSize = BitConverter.ToInt32(_readBuffer, 0); - int missingBytesToReadOfWholeMessage = currentMessageSize; - - // Read the message body in chunks, using _readBuffer as a transfer buffer. - while (missingBytesToReadOfWholeMessage > 0) - { - int toRead = Math.Min(_readBuffer.Length, missingBytesToReadOfWholeMessage); -#if NET - int n = await stream.ReadAsync(_readBuffer.AsMemory(0, toRead), cancellationToken).ConfigureAwait(false); -#else - int n = await stream.ReadAsync(_readBuffer, 0, toRead, cancellationToken).ConfigureAwait(false); -#endif - if (n == 0) - { - // EOF mid-message – treat the same as a clean disconnect. - return null; - } - -#if NET - await _messageBuffer.WriteAsync(_readBuffer.AsMemory(0, n), cancellationToken).ConfigureAwait(false); -#else - await _messageBuffer.WriteAsync(_readBuffer, 0, n, cancellationToken).ConfigureAwait(false); -#endif - missingBytesToReadOfWholeMessage -= n; - } - - // Full message received – deserialize and return - _messageBuffer.Position = 0; - int serializerId = BitConverter.ToInt32(_messageBuffer.GetBuffer(), 0); - INamedPipeSerializer namedPipeSerializer = GetSerializer(serializerId); - _messageBuffer.Position += sizeof(int); // skip the serializer ID - return namedPipeSerializer.Deserialize(_messageBuffer); - } - finally - { - _messageBuffer.Position = 0; - } - } - - /// Disposes the shared serialization and message buffers. - protected void DisposeBuffers() - { - _serializationBuffer.Dispose(); - _messageBuffer.Dispose(); - } } diff --git a/src/Platform/Microsoft.Testing.Platform/IPC/NamedPipeClient.cs b/src/Platform/Microsoft.Testing.Platform/IPC/NamedPipeClient.cs index bfcdedd92b..a62cb16e98 100644 --- a/src/Platform/Microsoft.Testing.Platform/IPC/NamedPipeClient.cs +++ b/src/Platform/Microsoft.Testing.Platform/IPC/NamedPipeClient.cs @@ -12,7 +12,7 @@ namespace Microsoft.Testing.Platform.IPC; #if !MTP_MSBUILD_TASKS [UnsupportedOSPlatform("browser")] #endif -internal sealed class NamedPipeClient : NamedPipeBase, IClient +internal sealed class NamedPipeClient : NamedPipeConnectionBase, IClient { private const PipeOptions AsyncCurrentUserPipeOptions = PipeOptions.Asynchronous #if NET diff --git a/src/Platform/Microsoft.Testing.Platform/IPC/NamedPipeConnectionBase.cs b/src/Platform/Microsoft.Testing.Platform/IPC/NamedPipeConnectionBase.cs new file mode 100644 index 0000000000..b183ca3418 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Platform/IPC/NamedPipeConnectionBase.cs @@ -0,0 +1,196 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +#if NET +using System.Buffers; +#endif + +using System.IO.Pipes; + +using Microsoft.CodeAnalysis; +#if NET +using Microsoft.Testing.Platform.Helpers; +#endif + +namespace Microsoft.Testing.Platform.IPC; + +// Named-pipe framing/transport shared by NamedPipeServer and NamedPipeClient. This is deliberately NOT part of the +// source-shared serializer registry (NamedPipeBase): the transport differs per repo (e.g. dotnet/sdk inlines its own +// read/write loop), so only the registry + serializers are shared. This type stays local to Microsoft.Testing.Platform. +[Embedded] +[SuppressMessage("Design", "CA1001:Types that own disposable fields should be disposable", Justification = "Disposal is delegated to subclasses via DisposeBuffers().")] +internal abstract class NamedPipeConnectionBase : NamedPipeBase +{ + private readonly MemoryStream _serializationBuffer = new(); + private readonly MemoryStream _messageBuffer = new(); + private readonly byte[] _readBuffer = new byte[250000]; + + /// + /// Serializes using , frames it with a + /// 4-byte size header and 4-byte serializer-ID prefix, writes the frame to , + /// flushes, and (on Windows) waits for the pipe to drain. + /// +#if !MTP_MSBUILD_TASKS + [UnsupportedOSPlatform("browser")] +#endif + protected async Task WriteMessageAsync(PipeStream stream, INamedPipeSerializer serializer, object message, CancellationToken cancellationToken) + { + // Serialize the message body + _serializationBuffer.Position = 0; + serializer.Serialize(message, _serializationBuffer); + + // Build the framed message: + // 4 bytes – total payload length (serializer ID + body) + // 4 bytes – serializer ID + // N bytes – serialized body + _messageBuffer.Position = 0; + int sizeOfTheWholeMessage = (int)_serializationBuffer.Position + sizeof(int); + + try + { + // Write the message size header +#if NET + byte[] bytes = ArrayPool.Shared.Rent(sizeof(int)); + try + { + if (!BitConverter.TryWriteBytes(bytes, sizeOfTheWholeMessage)) + { + // TryWriteBytes only fails if destination is too small; we rented at least sizeof(int). + throw ApplicationStateGuard.Unreachable(); + } + + await _messageBuffer.WriteAsync(bytes.AsMemory(0, sizeof(int)), cancellationToken).ConfigureAwait(false); + } + finally + { + ArrayPool.Shared.Return(bytes); + } +#else + await _messageBuffer.WriteAsync(BitConverter.GetBytes(sizeOfTheWholeMessage), 0, sizeof(int), cancellationToken).ConfigureAwait(false); +#endif + + // Write the serializer ID +#if NET + bytes = ArrayPool.Shared.Rent(sizeof(int)); + try + { + if (!BitConverter.TryWriteBytes(bytes, serializer.Id)) + { + throw ApplicationStateGuard.Unreachable(); + } + + await _messageBuffer.WriteAsync(bytes.AsMemory(0, sizeof(int)), cancellationToken).ConfigureAwait(false); + } + finally + { + ArrayPool.Shared.Return(bytes); + } +#else + await _messageBuffer.WriteAsync(BitConverter.GetBytes(serializer.Id), 0, sizeof(int), cancellationToken).ConfigureAwait(false); +#endif + + // Write the serialized payload +#if NET + await _messageBuffer.WriteAsync(_serializationBuffer.GetBuffer().AsMemory(0, (int)_serializationBuffer.Position), cancellationToken).ConfigureAwait(false); +#else + await _messageBuffer.WriteAsync(_serializationBuffer.GetBuffer(), 0, (int)_serializationBuffer.Position, cancellationToken).ConfigureAwait(false); +#endif + + // Send the framed message to the pipe stream +#if NET + await stream.WriteAsync(_messageBuffer.GetBuffer().AsMemory(0, (int)_messageBuffer.Position), cancellationToken).ConfigureAwait(false); +#else + await stream.WriteAsync(_messageBuffer.GetBuffer(), 0, (int)_messageBuffer.Position, cancellationToken).ConfigureAwait(false); +#endif + await stream.FlushAsync(cancellationToken).ConfigureAwait(false); + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + stream.WaitForPipeDrain(); + } + } + finally + { + _messageBuffer.Position = 0; + _serializationBuffer.Position = 0; + } + } + + /// + /// Reads one complete framed message from , deserializes it, and returns + /// the result. Returns when the stream reaches EOF (peer disconnected). + /// +#if !MTP_MSBUILD_TASKS + [UnsupportedOSPlatform("browser")] +#endif + protected async Task ReadNextMessageAsync(PipeStream stream, CancellationToken cancellationToken) + { + _messageBuffer.Position = 0; + + try + { + // Read the 4-byte size header. PipeStream.ReadAsync may return fewer bytes than requested + // (e.g. in byte-mode pipes), so we must loop until we've accumulated the full header or hit EOF. + int headerBytesRead = 0; + while (headerBytesRead < sizeof(int)) + { +#if NET + int n = await stream.ReadAsync(_readBuffer.AsMemory(headerBytesRead, sizeof(int) - headerBytesRead), cancellationToken).ConfigureAwait(false); +#else + int n = await stream.ReadAsync(_readBuffer, headerBytesRead, sizeof(int) - headerBytesRead, cancellationToken).ConfigureAwait(false); +#endif + if (n == 0) + { + // EOF – peer disconnected (cleanly if headerBytesRead == 0, mid-header otherwise); + // caller decides how to handle this. + return null; + } + + headerBytesRead += n; + } + + int currentMessageSize = BitConverter.ToInt32(_readBuffer, 0); + int missingBytesToReadOfWholeMessage = currentMessageSize; + + // Read the message body in chunks, using _readBuffer as a transfer buffer. + while (missingBytesToReadOfWholeMessage > 0) + { + int toRead = Math.Min(_readBuffer.Length, missingBytesToReadOfWholeMessage); +#if NET + int n = await stream.ReadAsync(_readBuffer.AsMemory(0, toRead), cancellationToken).ConfigureAwait(false); +#else + int n = await stream.ReadAsync(_readBuffer, 0, toRead, cancellationToken).ConfigureAwait(false); +#endif + if (n == 0) + { + // EOF mid-message – treat the same as a clean disconnect. + return null; + } + +#if NET + await _messageBuffer.WriteAsync(_readBuffer.AsMemory(0, n), cancellationToken).ConfigureAwait(false); +#else + await _messageBuffer.WriteAsync(_readBuffer, 0, n, cancellationToken).ConfigureAwait(false); +#endif + missingBytesToReadOfWholeMessage -= n; + } + + // Full message received – deserialize and return + _messageBuffer.Position = 0; + int serializerId = BitConverter.ToInt32(_messageBuffer.GetBuffer(), 0); + INamedPipeSerializer namedPipeSerializer = GetSerializer(serializerId); + _messageBuffer.Position += sizeof(int); // skip the serializer ID + return namedPipeSerializer.Deserialize(_messageBuffer); + } + finally + { + _messageBuffer.Position = 0; + } + } + + /// Disposes the shared serialization and message buffers. + protected void DisposeBuffers() + { + _serializationBuffer.Dispose(); + _messageBuffer.Dispose(); + } +} diff --git a/src/Platform/Microsoft.Testing.Platform/IPC/NamedPipeServer.cs b/src/Platform/Microsoft.Testing.Platform/IPC/NamedPipeServer.cs index 111beeaf4a..4b9bd682dd 100644 --- a/src/Platform/Microsoft.Testing.Platform/IPC/NamedPipeServer.cs +++ b/src/Platform/Microsoft.Testing.Platform/IPC/NamedPipeServer.cs @@ -15,7 +15,7 @@ namespace Microsoft.Testing.Platform.IPC; #if !MTP_MSBUILD_TASKS [UnsupportedOSPlatform("browser")] #endif -internal sealed class NamedPipeServer : NamedPipeBase, IServer +internal sealed class NamedPipeServer : NamedPipeConnectionBase, IServer { private const PipeOptions AsyncCurrentUserPipeOptions = PipeOptions.Asynchronous #if NET diff --git a/src/Platform/Microsoft.Testing.Platform/IPC/Serializers/BaseSerializer.cs b/src/Platform/Microsoft.Testing.Platform/IPC/Serializers/BaseSerializer.cs index 7825900b1c..f99e53e128 100644 --- a/src/Platform/Microsoft.Testing.Platform/IPC/Serializers/BaseSerializer.cs +++ b/src/Platform/Microsoft.Testing.Platform/IPC/Serializers/BaseSerializer.cs @@ -6,13 +6,27 @@ #endif using Microsoft.CodeAnalysis; -using Microsoft.Testing.Platform.Helpers; namespace Microsoft.Testing.Platform.IPC.Serializers; [Embedded] internal abstract class BaseSerializer { + // Self-contained DEBUG assert so this shared-source type has no dependency on the rest of + // Microsoft.Testing.Platform (e.g. RoslynDebug). Replaces RoslynDebug.Assert in the serializers. + // No [DoesNotReturnIf(false)]: this is [Conditional("DEBUG")] and delegates to Debug.Assert, which can + // return when the condition is false - so the annotation would be misleading and would force down-level + // consumers to also carry the DoesNotReturnIfAttribute polyfill. + [Conditional("DEBUG")] + [SuppressMessage("ApiDesign", "RS0030:Do not use banned APIs", Justification = "Self-contained replacement for RoslynDebug in shared IPC source.")] + protected static void DebugAssert(bool condition, string message) + => Debug.Assert(condition, message); + + // Internal invariant-violation diagnostic for "impossible" states (e.g. BitConverter.TryWriteBytes into a + // correctly-sized buffer). Kept self-contained (no ApplicationStateGuard) so this file shares as source. + private static InvalidOperationException Unreachable([CallerFilePath] string? path = null, [CallerLineNumber] int line = 0) + => new(string.Format(CultureInfo.InvariantCulture, "This program location is thought to be unreachable. File='{0}' Line={1}", path, line)); + #if NETCOREAPP protected static string ReadString(Stream stream) { @@ -54,7 +68,7 @@ protected static void WriteString(Stream stream, string str) Span len = stackalloc byte[sizeof(int)]; if (!BitConverter.TryWriteBytes(len, stringutf8TotalBytes)) { - throw ApplicationStateGuard.Unreachable(); + throw Unreachable(); } stream.Write(len); @@ -76,7 +90,7 @@ protected static void WriteSize(Stream stream) if (!BitConverter.TryWriteBytes(len, sizeInBytes)) { - throw ApplicationStateGuard.Unreachable(); + throw Unreachable(); } stream.Write(len); @@ -87,7 +101,7 @@ protected static void WriteInt(Stream stream, int value) Span bytes = stackalloc byte[sizeof(int)]; if (!BitConverter.TryWriteBytes(bytes, value)) { - throw ApplicationStateGuard.Unreachable(); + throw Unreachable(); } stream.Write(bytes); @@ -98,7 +112,7 @@ protected static void WriteLong(Stream stream, long value) Span bytes = stackalloc byte[sizeof(long)]; if (!BitConverter.TryWriteBytes(bytes, value)) { - throw ApplicationStateGuard.Unreachable(); + throw Unreachable(); } stream.Write(bytes); @@ -109,7 +123,7 @@ protected static void WriteUShort(Stream stream, ushort value) Span bytes = stackalloc byte[sizeof(ushort)]; if (!BitConverter.TryWriteBytes(bytes, value)) { - throw ApplicationStateGuard.Unreachable(); + throw Unreachable(); } stream.Write(bytes); @@ -120,7 +134,7 @@ protected static void WriteBool(Stream stream, bool value) Span bytes = stackalloc byte[sizeof(bool)]; if (!BitConverter.TryWriteBytes(bytes, value)) { - throw ApplicationStateGuard.Unreachable(); + throw Unreachable(); } stream.Write(bytes); @@ -344,7 +358,7 @@ protected static void WriteAtPosition(Stream stream, int value, long position) Type type when type == typeof(ushort) => sizeof(ushort), Type type when type == typeof(bool) => sizeof(bool), Type type when type == typeof(byte) => sizeof(byte), - _ => throw ApplicationStateGuard.Unreachable(), + _ => throw Unreachable(), }; public static bool IsNullOrEmpty(T[]? list) => list is null || list.Length == 0; diff --git a/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/DotnetTestProtocolContract.props b/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/DotnetTestProtocolContract.props index 80aea90a47..8a65041ab3 100644 --- a/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/DotnetTestProtocolContract.props +++ b/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/DotnetTestProtocolContract.props @@ -1,38 +1,76 @@ + + <_DotnetTestProtocolPlatform>$(MSBuildThisFileDirectory)..\.. + + + + + + + + + + + + + + + + + + + diff --git a/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/DotnetTestDataConsumer.cs b/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/DotnetTestDataConsumer.cs index 5b58a12a51..96d007ed92 100644 --- a/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/DotnetTestDataConsumer.cs +++ b/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/DotnetTestDataConsumer.cs @@ -61,12 +61,13 @@ public async Task ConsumeAsync(IDataProducer dataProducer, IData value, Cancella // `dotnet test --list-tests json`). Plain runs keep the payload minimal. TestFileLocationProperty? testFileLocationProperty = null; TestMethodIdentifierProperty? testMethodIdentifierProperty = null; - TestMetadataProperty[] traits = []; + TraitMessage[] traits = []; if (_dotnetTestConnection.IsIDE) { testFileLocationProperty = testNodeUpdateMessage.TestNode.Properties.SingleOrDefault(); testMethodIdentifierProperty = testNodeUpdateMessage.TestNode.Properties.SingleOrDefault(); - traits = testNodeDetails.Traits; + // Convert the platform's TestMetadataProperty to the shared, platform-decoupled wire type. + traits = [.. testNodeDetails.Traits.Select(static t => new TraitMessage(t.Key, t.Value))]; } DiscoveredTestMessages discoveredTestMessages = new( diff --git a/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/Models/DiscoveredTestMessages.cs b/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/Models/DiscoveredTestMessages.cs index 1f2275d1c9..df82db6cd3 100644 --- a/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/Models/DiscoveredTestMessages.cs +++ b/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/Models/DiscoveredTestMessages.cs @@ -1,10 +1,14 @@ // 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.Messages; - namespace Microsoft.Testing.Platform.IPC.Models; -internal sealed record DiscoveredTestMessage(string? Uid, string? DisplayName, string? FilePath, int? LineNumber, string? Namespace, string? TypeName, string? MethodName, string[]? ParameterTypeFullNames, TestMetadataProperty[] Traits); +// A single test trait (key/value metadata) carried over the pipe. This is the wire type shared with the SDK; it is +// deliberately decoupled from the public TestMetadataProperty (which is IProperty/TestNode-coupled) so this model can +// be compiled standalone into a consumer that has no reference to Microsoft.Testing.Platform. The host converts +// TestMetadataProperty -> TraitMessage at the boundary; the wire bytes (TraitMessageFieldsId.Key/.Value) are unchanged. +internal sealed record TraitMessage(string Key, string Value); + +internal sealed record DiscoveredTestMessage(string? Uid, string? DisplayName, string? FilePath, int? LineNumber, string? Namespace, string? TypeName, string? MethodName, string[]? ParameterTypeFullNames, TraitMessage[] Traits); internal sealed record DiscoveredTestMessages(string? ExecutionId, string? InstanceId, DiscoveredTestMessage[] DiscoveredMessages) : IRequest; 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 index e5f568d11a..bc9402a8a9 100644 --- a/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/Serializers/AzureDevOpsLogMessageSerializer.cs +++ b/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/Serializers/AzureDevOpsLogMessageSerializer.cs @@ -64,7 +64,7 @@ protected override AzureDevOpsLogMessage DeserializeCore(Stream stream) protected override void SerializeCore(AzureDevOpsLogMessage objectToSerialize, Stream stream) { - RoslynDebug.Assert(stream.CanSeek, "We expect a seekable stream."); + DebugAssert(stream.CanSeek, "We expect a seekable stream."); WriteUShort(stream, GetFieldCount(objectToSerialize)); diff --git a/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/Serializers/CommandLineOptionMessagesSerializer.cs b/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/Serializers/CommandLineOptionMessagesSerializer.cs index 1824e060ac..7d467ef786 100644 --- a/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/Serializers/CommandLineOptionMessagesSerializer.cs +++ b/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/Serializers/CommandLineOptionMessagesSerializer.cs @@ -121,7 +121,7 @@ private static CommandLineOptionMessage[] ReadCommandLineOptionMessagesPayload(S protected override void SerializeCore(CommandLineOptionMessages objectToSerialize, Stream stream) { - RoslynDebug.Assert(stream.CanSeek, "We expect a seekable stream."); + DebugAssert(stream.CanSeek, "We expect a seekable stream."); WriteUShort(stream, GetFieldCount(objectToSerialize)); diff --git a/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/Serializers/DiscoveredTestMessagesSerializer.cs b/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/Serializers/DiscoveredTestMessagesSerializer.cs index f4fb366f79..85ccd3b68b 100644 --- a/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/Serializers/DiscoveredTestMessagesSerializer.cs +++ b/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/Serializers/DiscoveredTestMessagesSerializer.cs @@ -1,8 +1,6 @@ // 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.Messages; -using Microsoft.Testing.Platform.Helpers; using Microsoft.Testing.Platform.IPC.Models; namespace Microsoft.Testing.Platform.IPC.Serializers; @@ -131,7 +129,7 @@ private static DiscoveredTestMessage[] ReadDiscoveredTestMessagesPayload(Stream string? @namespace = null; string? typeName = null; string? methodName = null; - TestMetadataProperty[] traits = []; + TraitMessage[] traits = []; string[] parameterTypeFullNames = []; int fieldCount = ReadUShort(stream); @@ -204,10 +202,10 @@ private static string[] ReadParameterTypeFullNamesPayload(Stream stream) return parameterTypeFullNames; } - private static TestMetadataProperty[] ReadTraitsPayload(Stream stream) + private static TraitMessage[] ReadTraitsPayload(Stream stream) { int length = ReadInt(stream); - var traits = new TestMetadataProperty[length]; + var traits = new TraitMessage[length]; for (int i = 0; i < length; i++) { string? key = null; @@ -235,9 +233,9 @@ private static TestMetadataProperty[] ReadTraitsPayload(Stream stream) } } - _ = key ?? throw ApplicationStateGuard.Unreachable(); - _ = value ?? throw ApplicationStateGuard.Unreachable(); - traits[i] = new TestMetadataProperty(key, value); + _ = key ?? throw new InvalidOperationException("Trait key is required."); + _ = value ?? throw new InvalidOperationException("Trait value is required."); + traits[i] = new TraitMessage(key, value); } return traits; @@ -245,7 +243,7 @@ private static TestMetadataProperty[] ReadTraitsPayload(Stream stream) protected override void SerializeCore(DiscoveredTestMessages objectToSerialize, Stream stream) { - RoslynDebug.Assert(stream.CanSeek, "We expect a seekable stream."); + DebugAssert(stream.CanSeek, "We expect a seekable stream."); WriteUShort(stream, GetFieldCount(objectToSerialize)); @@ -289,7 +287,7 @@ private static void WriteDiscoveredTestMessagesPayload(Stream stream, Discovered WriteAtPosition(stream, (int)(stream.Position - before), before - sizeof(int)); } - private static void WriteTraitsPayload(Stream stream, TestMetadataProperty[]? traits) + private static void WriteTraitsPayload(Stream stream, TraitMessage[]? traits) { if (traits is null || traits.Length == 0) { @@ -304,7 +302,7 @@ private static void WriteTraitsPayload(Stream stream, TestMetadataProperty[]? tr long before = stream.Position; WriteInt(stream, traits.Length); - foreach (TestMetadataProperty trait in traits) + foreach (TraitMessage trait in traits) { WriteUShort(stream, GetFieldCount(trait)); @@ -358,7 +356,7 @@ private static ushort GetFieldCount(DiscoveredTestMessage discoveredTestMessage) (IsNullOrEmpty(discoveredTestMessage.ParameterTypeFullNames) ? 0 : 1) + (IsNullOrEmpty(discoveredTestMessage.Traits) ? 0 : 1)); - private static ushort GetFieldCount(TestMetadataProperty trait) => + private static ushort GetFieldCount(TraitMessage trait) => (ushort)((trait.Key is null ? 0 : 1) + (trait.Value is null ? 0 : 1)); } diff --git a/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/Serializers/DisplayMessageSerializer.cs b/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/Serializers/DisplayMessageSerializer.cs index 26c2ec1abb..055a64fc7a 100644 --- a/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/Serializers/DisplayMessageSerializer.cs +++ b/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/Serializers/DisplayMessageSerializer.cs @@ -82,7 +82,7 @@ protected override DisplayMessage DeserializeCore(Stream stream) protected override void SerializeCore(DisplayMessage objectToSerialize, Stream stream) { - RoslynDebug.Assert(stream.CanSeek, "We expect a seekable stream."); + DebugAssert(stream.CanSeek, "We expect a seekable stream."); WriteUShort(stream, GetFieldCount(objectToSerialize)); diff --git a/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/Serializers/FileArtifactMessagesSerializer.cs b/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/Serializers/FileArtifactMessagesSerializer.cs index 675f1b1ef0..65b2a587ab 100644 --- a/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/Serializers/FileArtifactMessagesSerializer.cs +++ b/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/Serializers/FileArtifactMessagesSerializer.cs @@ -145,7 +145,7 @@ private static FileArtifactMessage[] ReadFileArtifactMessagesPayload(Stream stre protected override void SerializeCore(FileArtifactMessages objectToSerialize, Stream stream) { - RoslynDebug.Assert(stream.CanSeek, "We expect a seekable stream."); + DebugAssert(stream.CanSeek, "We expect a seekable stream."); WriteUShort(stream, GetFieldCount(objectToSerialize)); diff --git a/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/Serializers/HandshakeMessageSerializer.cs b/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/Serializers/HandshakeMessageSerializer.cs index b68c4aa759..5b1e89d3fc 100644 --- a/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/Serializers/HandshakeMessageSerializer.cs +++ b/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/Serializers/HandshakeMessageSerializer.cs @@ -25,7 +25,7 @@ protected override HandshakeMessage DeserializeCore(Stream stream) protected override void SerializeCore(HandshakeMessage objectToSerialize, Stream stream) { - RoslynDebug.Assert(stream.CanSeek, "We expect a seekable stream."); + DebugAssert(stream.CanSeek, "We expect a seekable stream."); // Deserializer always expected fieldCount to be present. // We must write the count even if Properties is null or empty. diff --git a/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/Serializers/TestInProgressMessagesSerializer.cs b/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/Serializers/TestInProgressMessagesSerializer.cs index 2f88f8f9d1..b304d05000 100644 --- a/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/Serializers/TestInProgressMessagesSerializer.cs +++ b/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/Serializers/TestInProgressMessagesSerializer.cs @@ -113,7 +113,7 @@ private static TestInProgressMessage[] ReadInProgressMessagesPayload(Stream stre protected override void SerializeCore(TestInProgressMessages objectToSerialize, Stream stream) { - RoslynDebug.Assert(stream.CanSeek, "We expect a seekable stream."); + DebugAssert(stream.CanSeek, "We expect a seekable stream."); WriteUShort(stream, GetFieldCount(objectToSerialize)); diff --git a/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/Serializers/TestResultMessagesSerializer.cs b/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/Serializers/TestResultMessagesSerializer.cs index fb1a6e00ca..9763461da0 100644 --- a/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/Serializers/TestResultMessagesSerializer.cs +++ b/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/Serializers/TestResultMessagesSerializer.cs @@ -330,7 +330,7 @@ private static ExceptionMessage[] ReadExceptionMessagesPayload(Stream stream) protected override void SerializeCore(TestResultMessages objectToSerialize, Stream stream) { - RoslynDebug.Assert(stream.CanSeek, "We expect a seekable stream."); + DebugAssert(stream.CanSeek, "We expect a seekable stream."); WriteUShort(stream, GetFieldCount(objectToSerialize)); diff --git a/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/Serializers/TestSessionEventSerializer.cs b/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/Serializers/TestSessionEventSerializer.cs index b2eed21a5f..95c2c5b069 100644 --- a/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/Serializers/TestSessionEventSerializer.cs +++ b/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/Serializers/TestSessionEventSerializer.cs @@ -64,7 +64,7 @@ protected override TestSessionEvent DeserializeCore(Stream stream) protected override void SerializeCore(TestSessionEvent objectToSerialize, Stream stream) { - RoslynDebug.Assert(stream.CanSeek, "We expect a seekable stream."); + DebugAssert(stream.CanSeek, "We expect a seekable stream."); WriteUShort(stream, GetFieldCount(objectToSerialize)); diff --git a/test/UnitTests/Microsoft.Testing.Platform.DotnetTestProtocolContract.UnitTests/DotnetTestProtocolContractTests.cs b/test/UnitTests/Microsoft.Testing.Platform.DotnetTestProtocolContract.UnitTests/DotnetTestProtocolContractTests.cs index 5a7021990d..4fd2133ca1 100644 --- a/test/UnitTests/Microsoft.Testing.Platform.DotnetTestProtocolContract.UnitTests/DotnetTestProtocolContractTests.cs +++ b/test/UnitTests/Microsoft.Testing.Platform.DotnetTestProtocolContract.UnitTests/DotnetTestProtocolContractTests.cs @@ -6,15 +6,17 @@ namespace Microsoft.Testing.Platform.DotnetTestProtocolContract.UnitTests; /// -/// Verifies the shared 'dotnet test' wire-contract source (ObjectFieldIds + Constants), compiled into this -/// independent assembly via DotnetTestProtocolContract.props. +/// Verifies the shared 'dotnet test' wire contract (serializer/field ids in ObjectFieldIds + the +/// handshake/session/state values in Constants), compiled into this independent assembly via +/// DotnetTestProtocolContract.props alongside the rest of the shared serializer stack. /// /// -/// This project does not reference Microsoft.Testing.Platform's protocol types; it compiles the same source files. -/// The assertions pin every value that flows over the pipe and is mirrored by hand in dotnet/sdk's -/// ObjectFieldIds, so a drift between the two repositories - or an accidental change to the contract - fails -/// the build here. The fact that this assembly compiles at all is the proof that the contract is self-contained and -/// consumable with a single source of truth. +/// This project does not reference Microsoft.Testing.Platform's protocol types; it compiles the same source files +/// (the whole serializer stack for ids 0-12 - see for the round-trip +/// coverage). The assertions here pin every value that flows over the pipe, so an accidental change to the contract - +/// or drift from the copy dotnet/sdk consumes from this same source - fails the build here. The fact that this +/// assembly compiles at all is the proof that the contract is self-contained and consumable with a single source of +/// truth. /// [TestClass] public sealed class DotnetTestProtocolContractTests diff --git a/test/UnitTests/Microsoft.Testing.Platform.DotnetTestProtocolContract.UnitTests/DotnetTestProtocolSerializerTests.cs b/test/UnitTests/Microsoft.Testing.Platform.DotnetTestProtocolContract.UnitTests/DotnetTestProtocolSerializerTests.cs new file mode 100644 index 0000000000..1b62e1765e --- /dev/null +++ b/test/UnitTests/Microsoft.Testing.Platform.DotnetTestProtocolContract.UnitTests/DotnetTestProtocolSerializerTests.cs @@ -0,0 +1,280 @@ +// 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; +using Microsoft.Testing.Platform.IPC.Models; +using Microsoft.Testing.Platform.IPC.Serializers; + +namespace Microsoft.Testing.Platform.DotnetTestProtocolContract.UnitTests; + +/// +/// Round-trips the shared 'dotnet test' message serializers (ids 0-12) - models, serializers, the serializer +/// registry ( + ) and the decoupled +/// - compiled into this independent assembly via DotnetTestProtocolContract.props. +/// +/// +/// This project does NOT reference Microsoft.Testing.Platform; it compiles the same source files. The fact that this +/// assembly compiles at all proves the whole serializer stack is self-contained; the round-trips below prove the wire +/// bytes survive a serialize/deserialize cycle in a consumer with a single source of truth. This mirrors +/// ProtocolTests in Microsoft.Testing.Platform.UnitTests; keep both in sync. +/// +[TestClass] +public sealed class DotnetTestProtocolSerializerTests +{ + private static T RoundTrip(NamedPipeSerializer serializer, T message) + where T : notnull + { + using var stream = new MemoryStream(); + serializer.Serialize(message, stream); + stream.Position = 0; + return serializer.Deserialize(stream); + } + + // Exposes the protected registry lookup so the test can assert every id 0-12 is registered. + // Exposes the protected registry lookups so the test can assert both the id-based mapping (every id 0-12 is + // registered) and the type-based mapping (GetSerializer(Type), used by the runtime serialize path). + private sealed class SerializerRegistry : NamedPipeBase + { + public INamedPipeSerializer Get(int id) => GetSerializer(id); + + public INamedPipeSerializer GetByType(Type type) => GetSerializer(type); + } + + [TestMethod] + public void RegisterAllSerializers_RegistersEveryWireId() + { + var registry = new SerializerRegistry(); + registry.RegisterAllSerializers(); + + Assert.AreEqual(VoidResponseFieldsId.MessagesSerializerId, registry.Get(0).Id); + Assert.AreEqual(TestHostCompletedRequestFieldsId.MessagesSerializerId, registry.Get(1).Id); + Assert.AreEqual(TestHostProcessPIDRequestFieldsId.MessagesSerializerId, registry.Get(2).Id); + Assert.AreEqual(CommandLineOptionMessagesFieldsId.MessagesSerializerId, registry.Get(3).Id); + Assert.AreEqual(DiscoveredTestMessagesFieldsId.MessagesSerializerId, registry.Get(5).Id); + Assert.AreEqual(TestResultMessagesFieldsId.MessagesSerializerId, registry.Get(6).Id); + Assert.AreEqual(FileArtifactMessagesFieldsId.MessagesSerializerId, registry.Get(7).Id); + Assert.AreEqual(TestSessionEventFieldsId.MessagesSerializerId, registry.Get(8).Id); + Assert.AreEqual(HandshakeMessageFieldsId.MessagesSerializerId, registry.Get(9).Id); + Assert.AreEqual(TestInProgressMessagesFieldsId.MessagesSerializerId, registry.Get(10).Id); + // The two ids that used to be dropped at the SDK because they were never registered. + Assert.AreEqual(AzureDevOpsLogMessageFieldsId.MessagesSerializerId, registry.Get(11).Id); + Assert.AreEqual(DisplayMessageFieldsId.MessagesSerializerId, registry.Get(12).Id); + + // Reserved id 4 (formerly ModuleSerializer) must remain unregistered. + Assert.ThrowsExactly(() => registry.Get(4)); + + // Pin the concrete serializer TYPE (not just a matching Id value) for the two ids that were being + // dropped, so an accidental id/type misregistration is caught here too - both via id lookup and via the + // type-based lookup (GetSerializer(Type)) that the runtime serialize path actually uses. + Assert.IsInstanceOfType(registry.Get(11)); + Assert.IsInstanceOfType(registry.Get(12)); + Assert.IsInstanceOfType(registry.GetByType(typeof(AzureDevOpsLogMessage))); + Assert.IsInstanceOfType(registry.GetByType(typeof(DisplayMessage))); + } + + [TestMethod] + public void AzureDevOpsLogMessage_RoundTrips() + { + var message = new AzureDevOpsLogMessage("MyExecId", "MyInstId", "##[group]Tests: MyAssembly (net9.0)"); + + AzureDevOpsLogMessage actual = RoundTrip(new AzureDevOpsLogMessageSerializer(), message); + + Assert.AreEqual(message.ExecutionId, actual.ExecutionId); + Assert.AreEqual(message.InstanceId, actual.InstanceId); + Assert.AreEqual(message.LogText, actual.LogText); + } + + [TestMethod] + public void DisplayMessage_RoundTrips() + { + var message = new DisplayMessage("MyExecId", "MyInstId", 2, "A warning from the host"); + + DisplayMessage actual = RoundTrip(new DisplayMessageSerializer(), message); + + Assert.AreEqual(message.ExecutionId, actual.ExecutionId); + Assert.AreEqual(message.InstanceId, actual.InstanceId); + Assert.AreEqual(message.Level, actual.Level); + Assert.AreEqual(message.Text, actual.Text); + } + + [TestMethod] + public void HandshakeMessage_RoundTrips() + { + var message = new HandshakeMessage(new Dictionary + { + [HandshakeMessagePropertyNames.PID] = "1234", + [HandshakeMessagePropertyNames.SupportedProtocolVersions] = ProtocolConstants.SupportedVersions, + }); + + HandshakeMessage actual = RoundTrip(new HandshakeMessageSerializer(), message); + + Assert.IsNotNull(actual.Properties); + Assert.AreEqual("1234", actual.Properties[HandshakeMessagePropertyNames.PID]); + Assert.AreEqual(ProtocolConstants.SupportedVersions, actual.Properties[HandshakeMessagePropertyNames.SupportedProtocolVersions]); + } + + [TestMethod] + public void TestResultMessages_RoundTrips() + { + var success = new SuccessfulTestResultMessage("uid", "displayName", TestStates.Passed, 100, "reason", "standardOutput", "errorOutput", "sessionUid"); + var fail = new FailedTestResultMessage("uid2", "displayName2", TestStates.Failed, 200, "reason", [new ExceptionMessage("errorMessage", "errorType", "stackTrace")], "standardOutput", "errorOutput", "sessionUid"); + var message = new TestResultMessages("executionId", "instanceId", [success], [fail]); + + TestResultMessages actual = RoundTrip(new TestResultMessagesSerializer(), message); + + Assert.AreEqual(message.ExecutionId, actual.ExecutionId); + Assert.AreEqual(message.InstanceId, actual.InstanceId); + Assert.AreEqual("uid", actual.SuccessfulTestMessages[0].Uid); + Assert.AreEqual("errorMessage", actual.FailedTestMessages[0].Exceptions?[0].ErrorMessage); + } + + [TestMethod] + public void DiscoveredTestMessages_RoundTripsWithTraitsAndParameters() + { + var message = new DiscoveredTestMessages( + "executionId", + "instanceId", + [ + new DiscoveredTestMessage("Uid1", "Display1", "file1.cs", 19, "NS1", "Type1", "TM1", ["p1", "p2"], [new TraitMessage("Key1", "Value1"), new TraitMessage("Key2", string.Empty)]), + new DiscoveredTestMessage("Uid2", "Display2", null, null, "NS2", "Type2", "TM2", [], []), + ]); + + DiscoveredTestMessages actual = RoundTrip(new DiscoveredTestMessagesSerializer(), message); + + Assert.AreEqual(message.ExecutionId, actual.ExecutionId); + Assert.HasCount(2, actual.DiscoveredMessages); + + DiscoveredTestMessage first = actual.DiscoveredMessages[0]; + Assert.AreEqual("Uid1", first.Uid); + Assert.HasCount(2, first.ParameterTypeFullNames!); + Assert.HasCount(2, first.Traits); + Assert.AreEqual("Key1", first.Traits[0].Key); + Assert.AreEqual("Value1", first.Traits[0].Value); + Assert.AreEqual("Key2", first.Traits[1].Key); + Assert.AreEqual(string.Empty, first.Traits[1].Value); + + DiscoveredTestMessage second = actual.DiscoveredMessages[1]; + Assert.AreEqual("Uid2", second.Uid); + Assert.HasCount(0, second.Traits); + } + + [TestMethod] + public void VoidResponse_RoundTrips() + { + VoidResponse actual = RoundTrip(new VoidResponseSerializer(), VoidResponse.CachedInstance); + + Assert.IsNotNull(actual); + } + + [TestMethod] + public void TestHostCompletedRequest_RoundTrips() + { + TestHostCompletedRequest actual = RoundTrip(new TestHostCompletedRequestSerializer(), new TestHostCompletedRequest(42)); + + Assert.AreEqual(42, actual.ExitCode); + } + + [TestMethod] + public void TestHostProcessPIDRequest_RoundTrips() + { + TestHostProcessPIDRequest actual = RoundTrip(new TestHostProcessPIDRequestSerializer(), new TestHostProcessPIDRequest(1234)); + + Assert.AreEqual(1234, actual.PID); + } + + [TestMethod] + public void CommandLineOptionMessages_RoundTrips() + { + var message = new CommandLineOptionMessages( + "path/to/module.dll", + [ + new CommandLineOptionMessage("--filter", "Filter tests", false, true), + new CommandLineOptionMessage("--hidden", null, true, false), + ]); + + CommandLineOptionMessages actual = RoundTrip(new CommandLineOptionMessagesSerializer(), message); + + Assert.AreEqual(message.ModulePath, actual.ModulePath); + Assert.HasCount(2, actual.CommandLineOptionMessageList!); + Assert.AreEqual("--filter", actual.CommandLineOptionMessageList![0].Name); + Assert.AreEqual("Filter tests", actual.CommandLineOptionMessageList[0].Description); + Assert.IsFalse(actual.CommandLineOptionMessageList[0].IsHidden); + Assert.IsTrue(actual.CommandLineOptionMessageList[0].IsBuiltIn); + Assert.IsNull(actual.CommandLineOptionMessageList[1].Description); + Assert.IsTrue(actual.CommandLineOptionMessageList[1].IsHidden); + } + + [TestMethod] + public void FileArtifactMessages_RoundTrips() + { + var message = new FileArtifactMessages( + "executionId", + "instanceId", + [ + new FileArtifactMessage("full/path.txt", "artifact", "desc", "testUid", "testDisplay", "sessionUid"), + new FileArtifactMessage("other.txt", "other", null, null, null, null), + ]); + + FileArtifactMessages actual = RoundTrip(new FileArtifactMessagesSerializer(), message); + + Assert.AreEqual(message.ExecutionId, actual.ExecutionId); + Assert.HasCount(2, actual.FileArtifacts); + Assert.AreEqual("full/path.txt", actual.FileArtifacts[0].FullPath); + Assert.AreEqual("sessionUid", actual.FileArtifacts[0].SessionUid); + Assert.AreEqual("other.txt", actual.FileArtifacts[1].FullPath); + Assert.IsNull(actual.FileArtifacts[1].Description); + } + + [TestMethod] + public void TestSessionEvent_RoundTrips() + { + var message = new TestSessionEvent(SessionEventTypes.TestSessionStart, "sessionUid", "executionId"); + + TestSessionEvent actual = RoundTrip(new TestSessionEventSerializer(), message); + + Assert.AreEqual(SessionEventTypes.TestSessionStart, actual.SessionType); + Assert.AreEqual("sessionUid", actual.SessionUid); + Assert.AreEqual("executionId", actual.ExecutionId); + } + + [TestMethod] + public void TestInProgressMessages_RoundTrips() + { + var message = new TestInProgressMessages( + "executionId", + "instanceId", + [new TestInProgressMessage("uid1", "display1"), new TestInProgressMessage("uid2", "display2")]); + + TestInProgressMessages actual = RoundTrip(new TestInProgressMessagesSerializer(), message); + + Assert.AreEqual(message.ExecutionId, actual.ExecutionId); + Assert.HasCount(2, actual.InProgressMessages); + Assert.AreEqual("uid1", actual.InProgressMessages[0].Uid); + Assert.AreEqual("display2", actual.InProgressMessages[1].DisplayName); + } + + [TestMethod] + public void AzureDevOpsLogMessage_RoundTripsWithNullOptionalFields() + { + var message = new AzureDevOpsLogMessage(null, null, "##[endgroup]"); + + AzureDevOpsLogMessage actual = RoundTrip(new AzureDevOpsLogMessageSerializer(), message); + + Assert.IsNull(actual.ExecutionId); + Assert.IsNull(actual.InstanceId); + Assert.AreEqual("##[endgroup]", actual.LogText); + } + + [TestMethod] + public void DisplayMessage_RoundTripsWithNullOptionalFields() + { + var message = new DisplayMessage(null, null, 0, null); + + DisplayMessage actual = RoundTrip(new DisplayMessageSerializer(), message); + + Assert.IsNull(actual.ExecutionId); + Assert.IsNull(actual.InstanceId); + Assert.AreEqual(0, actual.Level); + Assert.IsNull(actual.Text); + } +} diff --git a/test/UnitTests/Microsoft.Testing.Platform.DotnetTestProtocolContract.UnitTests/Microsoft.Testing.Platform.DotnetTestProtocolContract.UnitTests.csproj b/test/UnitTests/Microsoft.Testing.Platform.DotnetTestProtocolContract.UnitTests/Microsoft.Testing.Platform.DotnetTestProtocolContract.UnitTests.csproj index 92fb668730..52394a4689 100644 --- a/test/UnitTests/Microsoft.Testing.Platform.DotnetTestProtocolContract.UnitTests/Microsoft.Testing.Platform.DotnetTestProtocolContract.UnitTests.csproj +++ b/test/UnitTests/Microsoft.Testing.Platform.DotnetTestProtocolContract.UnitTests/Microsoft.Testing.Platform.DotnetTestProtocolContract.UnitTests.csproj @@ -12,20 +12,25 @@ - +