From 282baaf639962148fe9a729816ccd1802cd0ed81 Mon Sep 17 00:00:00 2001 From: Drew Noakes Date: Tue, 5 May 2026 19:52:42 +1000 Subject: [PATCH 01/12] Make TranslationLayer NativeAOT-compatible The `Microsoft.TestPlatform.VsTestConsole.TranslationLayer` and `Microsoft.TestPlatform.CommunicationUtilities` assemblies are loaded in-process by NativeAOT consumers (e.g. C# Dev Kit). NativeAOT disables reflection-based `System.Text.Json` serialization by default, causing the vstest wire protocol to fail silently during the TCP handshake and test discovery. ## Changes ### Source-generated `JsonSerializerContext` Add `TestPlatformJsonContext` with `[JsonSerializable]` attributes for every type that crosses the wire: payload DTOs, envelope DTOs, collection types, and `PayloadedMessage` for each concrete `T` used by `VsTestConsoleRequestSender`. Set `TypeInfoResolver` on the base `JsonSerializerOptions` so STJ has compile-time metadata available without runtime reflection. ### AOT-safe serialization converters - **`TestObjectBaseConverterFactory`**: replace `MakeGenericType` + `Activator.CreateInstance` with a singleton non-generic converter. - **`ObjectConverter.Write`**, **`ObjectDictionaryConverter.Write`**, **`TestObjectConverter.Write`**, **`TestCaseConverterV2.Write`**, **`TestResultConverterV2.Write`**: replace `JsonSerializer.Serialize(writer, value, value.GetType(), options)` with direct primitive writes via a centralized `WritePropertyValue()` helper that handles string, int, long, double, float, bool, DateTimeOffset, DateTime, Guid, Uri, with `ToString()` fallback. ### Envelope DTO fixes - Change `MessageEnvelope.Payload` and `VersionedMessageEnvelope.Payload` from `object?` to `JsonElement?` so pre-serialized payloads are embedded as nested JSON objects rather than double-encoded strings. - Make `MessageEnvelope`, `VersionedMessageEnvelope`, `VersionedMessageForSerialization`, and `PayloadedMessage` `internal` (were `private`) so the source generator can reference them. - Use `JsonSerializer.SerializeToElement(payload, payload.GetType(), options)` to serialize payloads with runtime type dispatch before embedding in envelopes. ### AOT/trim analyzers enabled Set `IsAotCompatible=true`, `EnableTrimAnalyzer=true`, and `EnableAotAnalyzer=true` on the `net8.0` TFM for both `TranslationLayer` and `CommunicationUtilities` projects. Both build with zero IL2xxx/IL3xxx warnings. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../JsonDataSerializer.Stj.cs | 29 +++-- ...TestPlatform.CommunicationUtilities.csproj | 10 ++ .../Serialization/ObjectConverter.cs | 15 ++- .../Serialization/TestCaseConverterV2.cs | 4 +- .../Serialization/TestObjectBaseConverter.cs | 59 +++++++-- .../Serialization/TestObjectConverter.cs | 4 +- .../Serialization/TestResultConverterV2.cs | 4 +- .../TestPlatformJsonContext.cs | 119 ++++++++++++++++++ ...form.VsTestConsole.TranslationLayer.csproj | 7 ++ 9 files changed, 231 insertions(+), 20 deletions(-) create mode 100644 src/Microsoft.TestPlatform.CommunicationUtilities/TestPlatformJsonContext.cs diff --git a/src/Microsoft.TestPlatform.CommunicationUtilities/JsonDataSerializer.Stj.cs b/src/Microsoft.TestPlatform.CommunicationUtilities/JsonDataSerializer.Stj.cs index 6d7b2f8dcb..d0731f0b10 100644 --- a/src/Microsoft.TestPlatform.CommunicationUtilities/JsonDataSerializer.Stj.cs +++ b/src/Microsoft.TestPlatform.CommunicationUtilities/JsonDataSerializer.Stj.cs @@ -74,6 +74,10 @@ static JsonDataSerializer() NumberHandling = JsonNumberHandling.AllowReadingFromString, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, ReferenceHandler = ReferenceHandler.IgnoreCycles, + // Use the source-generated context so STJ has type metadata available + // without runtime reflection. This is required for NativeAOT consumers + // where reflection-based serialization is disabled by default. + TypeInfoResolver = TestPlatformJsonContext.Default, }; private static partial (int version, string? messageType) ParseHeaderFromJson(string rawMessage) @@ -188,7 +192,7 @@ private static partial string SerializePayloadCore(string? messageType, object? if (payload is null) return string.Empty; - var serializedPayload = JsonSerializer.SerializeToElement(payload, payloadOptions); + var serializedPayload = JsonSerializer.SerializeToElement(payload, payload.GetType(), payloadOptions); return version > 1 ? Serialize(DefaultOptions, new VersionedMessageEnvelope { MessageType = messageType, Version = version, Payload = serializedPayload }) : @@ -196,7 +200,16 @@ private static partial string SerializePayloadCore(string? messageType, object? } else { - return Serialize(FastOptions, new VersionedMessageForSerialization { MessageType = messageType, Version = version, Payload = payload }); + // Serialize the payload to a JsonElement first using the versioned options + // (which have the custom converters). Then embed it in the envelope. + // Use payload.GetType() to tell STJ the actual runtime type, since the + // parameter is typed as object? and source-gen can't infer it. + if (payload is null) + return string.Empty; + + var serializedPayload = JsonSerializer.SerializeToElement(payload, payload.GetType(), payloadOptions); + + return Serialize(DefaultOptions, new VersionedMessageEnvelope { MessageType = messageType, Version = version, Payload = serializedPayload }); } } @@ -258,7 +271,7 @@ private static JsonSerializerOptions GetPayloadOptions(int? version) /// This grabs payload from the message, we already know version and message type. /// /// - private class PayloadedMessage + internal class PayloadedMessage { public T? Payload { get; set; } } @@ -267,31 +280,31 @@ private class PayloadedMessage /// Serialization-only DTO for building the JSON wire format (without Version). /// NOT a Message — this is never returned to callers. /// - private class MessageEnvelope + internal class MessageEnvelope { public string? MessageType { get; set; } [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public object? Payload { get; set; } + public JsonElement? Payload { get; set; } } /// /// Serialization-only DTO for building the JSON wire format (with Version). /// NOT a Message — this is never returned to callers. /// - private class VersionedMessageEnvelope + internal class VersionedMessageEnvelope { public int Version { get; set; } public string? MessageType { get; set; } [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public object? Payload { get; set; } + public JsonElement? Payload { get; set; } } /// /// For serialization directly into string, without first converting to JsonElement, and then from JsonElement to string. /// - private class VersionedMessageForSerialization + internal class VersionedMessageForSerialization { /// /// Gets or sets the version of the message diff --git a/src/Microsoft.TestPlatform.CommunicationUtilities/Microsoft.TestPlatform.CommunicationUtilities.csproj b/src/Microsoft.TestPlatform.CommunicationUtilities/Microsoft.TestPlatform.CommunicationUtilities.csproj index b71d29933a..1c9da3aa29 100644 --- a/src/Microsoft.TestPlatform.CommunicationUtilities/Microsoft.TestPlatform.CommunicationUtilities.csproj +++ b/src/Microsoft.TestPlatform.CommunicationUtilities/Microsoft.TestPlatform.CommunicationUtilities.csproj @@ -5,6 +5,16 @@ $(NetFrameworkMinimum);$(ExtensionTargetFrameworks);$(NetCoreAppMinimum) false + + + + true + true + true + + $(WarningsNotAsErrors);IL2026;IL2057;IL2067;IL3050 + diff --git a/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/ObjectConverter.cs b/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/ObjectConverter.cs index a90e5930c1..009a1f59a2 100644 --- a/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/ObjectConverter.cs +++ b/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/ObjectConverter.cs @@ -63,7 +63,9 @@ internal class ObjectConverter : JsonConverter public override void Write(Utf8JsonWriter writer, object value, JsonSerializerOptions options) { - JsonSerializer.Serialize(writer, value, value.GetType(), options); + // Avoid JsonSerializer.Serialize(writer, value, value.GetType(), options) which requires + // reflection metadata that NativeAOT trims. Write known primitives directly. + TestObjectBaseConverter.WritePropertyValue(writer, value); } } @@ -120,7 +122,16 @@ public override void Write(Utf8JsonWriter writer, IDictionary va foreach (var kvp in value) { writer.WritePropertyName(kvp.Key); - JsonSerializer.Serialize(writer, kvp.Value, kvp.Value?.GetType() ?? typeof(object), options); + if (kvp.Value is null) + { + writer.WriteNullValue(); + } + else + { + // Avoid JsonSerializer.Serialize(writer, value, value.GetType(), options) which + // requires reflection metadata that NativeAOT trims. + TestObjectBaseConverter.WritePropertyValue(writer, kvp.Value); + } } writer.WriteEndObject(); diff --git a/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/TestCaseConverterV2.cs b/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/TestCaseConverterV2.cs index db5597757b..4accedfd6a 100644 --- a/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/TestCaseConverterV2.cs +++ b/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/TestCaseConverterV2.cs @@ -99,7 +99,9 @@ public override void Write(Utf8JsonWriter writer, TestCase value, JsonSerializer } else { - JsonSerializer.Serialize(writer, property.Value, property.Value.GetType(), options); + // Avoid JsonSerializer.Serialize(writer, value, value.GetType(), options) which + // requires reflection metadata that NativeAOT trims. + TestObjectBaseConverter.WritePropertyValue(writer, property.Value); } writer.WriteEndObject(); } diff --git a/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/TestObjectBaseConverter.cs b/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/TestObjectBaseConverter.cs index 1161799d4a..945388f921 100644 --- a/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/TestObjectBaseConverter.cs +++ b/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/TestObjectBaseConverter.cs @@ -20,6 +20,10 @@ namespace Microsoft.VisualStudio.TestPlatform.CommunicationUtilities.Serializati /// internal class TestObjectBaseConverterFactory : JsonConverterFactory { + // Singleton converter handles all TestObject-derived types via the base class, + // avoiding MakeGenericType + Activator.CreateInstance which fail under NativeAOT. + private static readonly TestObjectBaseConverter Converter = new(); + public override bool CanConvert(Type typeToConvert) { return typeof(TestObject).IsAssignableFrom(typeToConvert) @@ -29,16 +33,27 @@ public override bool CanConvert(Type typeToConvert) public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options) { - var converterType = typeof(TestObjectBaseConverter<>).MakeGenericType(typeToConvert); - return (JsonConverter?)Activator.CreateInstance(converterType); + return Converter; } } -internal class TestObjectBaseConverter : JsonConverter where T : TestObject, new() +internal class TestObjectBaseConverter : JsonConverter { - public override T? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + public override bool CanConvert(Type typeToConvert) { - var testObject = new T(); + return typeof(TestObject).IsAssignableFrom(typeToConvert) + && typeToConvert != typeof(TestCase) + && typeToConvert != typeof(TestResult); + } + + public override TestObject? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + // TestObject is abstract, but the only concrete non-TestCase/TestResult subclass + // that flows through the wire is TestObject itself (used as a generic bag). + // If the runtime type is unknown, we cannot instantiate it without reflection. + // Fall back to reading the property bag into a TestCase as a carrier, which + // preserves the property key-value pairs for the consumer. + var testObject = new TestCase(); using var doc = JsonDocument.ParseValue(ref reader); var data = doc.RootElement; @@ -79,7 +94,7 @@ public override bool CanConvert(Type typeToConvert) return testObject; } - public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) + public override void Write(Utf8JsonWriter writer, TestObject value, JsonSerializerOptions options) { writer.WriteStartObject(); @@ -97,7 +112,7 @@ public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions } else { - JsonSerializer.Serialize(writer, property.Value, property.Value.GetType(), options); + WritePropertyValue(writer, property.Value); } writer.WriteEndObject(); } @@ -105,6 +120,36 @@ public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions writer.WriteEndObject(); } + + /// + /// Writes a property value without using + /// JsonSerializer.Serialize(writer, value, value.GetType()) which requires + /// reflection metadata that NativeAOT trims. + /// + internal static void WritePropertyValue(Utf8JsonWriter writer, object value) + { + switch (value) + { + case string s: writer.WriteStringValue(s); break; + case int i: writer.WriteNumberValue(i); break; + case long l: writer.WriteNumberValue(l); break; + case double d: writer.WriteNumberValue(d); break; + case float f: writer.WriteNumberValue(f); break; + case bool b: writer.WriteBooleanValue(b); break; + case DateTimeOffset dto: writer.WriteStringValue(dto); break; + case DateTime dt: writer.WriteStringValue(dt); break; + case Guid g: writer.WriteStringValue(g); break; + case Uri u: writer.WriteStringValue(u.OriginalString); break; + case JsonElement je: je.WriteTo(writer); break; + default: + // For complex types (Traits, collections, etc.), serialize to JsonElement + // first using the runtime type, then write the element. This avoids the + // object? polymorphism problem while still producing valid JSON. + var element = JsonSerializer.SerializeToElement(value, value.GetType()); + element.WriteTo(writer); + break; + } + } } #endif diff --git a/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/TestObjectConverter.cs b/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/TestObjectConverter.cs index 829cda5142..d34fb735ce 100644 --- a/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/TestObjectConverter.cs +++ b/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/TestObjectConverter.cs @@ -83,7 +83,9 @@ public override void Write(Utf8JsonWriter writer, List +/// Source-generated that provides STJ type metadata +/// without runtime reflection. Required for NativeAOT consumers where reflection-based +/// serialization is disabled by default. +/// +/// Every type that flows through must be listed here. +/// Custom converters (TestCaseConverterV2, TestPropertyConverter, etc.) are registered +/// on the separately — this context provides the +/// fallback metadata for types the converters delegate to STJ for. +/// +// --- Primitive / built-in types used as payloads --- +[JsonSerializable(typeof(int))] +[JsonSerializable(typeof(long))] +[JsonSerializable(typeof(bool))] +[JsonSerializable(typeof(string))] +[JsonSerializable(typeof(double))] +// --- Envelope DTOs (serialization-only, used by JsonDataSerializer) --- +[JsonSerializable(typeof(JsonElement))] +// --- ObjectModel types that cross the wire --- +[JsonSerializable(typeof(TestCase))] +[JsonSerializable(typeof(TestResult))] +[JsonSerializable(typeof(TestProperty))] +[JsonSerializable(typeof(TestObject))] +[JsonSerializable(typeof(AttachmentSet))] +[JsonSerializable(typeof(TestRunStatistics))] +[JsonSerializable(typeof(IEnumerable))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(TestCase[]))] +[JsonSerializable(typeof(IEnumerable))] +[JsonSerializable(typeof(List>))] +[JsonSerializable(typeof(IEnumerable))] +[JsonSerializable(typeof(List))] +// --- Payload types deserialized by VsTestConsoleRequestSender --- +[JsonSerializable(typeof(DiscoveryCompletePayload))] +[JsonSerializable(typeof(DiscoveryRequestPayload))] +[JsonSerializable(typeof(TestMessagePayload))] +[JsonSerializable(typeof(TestRunCompletePayload))] +[JsonSerializable(typeof(TestRunChangedEventArgs))] +[JsonSerializable(typeof(TestRunStatsPayload))] +[JsonSerializable(typeof(StartTestSessionAckPayload))] +[JsonSerializable(typeof(StopTestSessionAckPayload))] +[JsonSerializable(typeof(TestProcessStartInfo))] +[JsonSerializable(typeof(EditorAttachDebuggerPayload))] +[JsonSerializable(typeof(TelemetryEvent))] +[JsonSerializable(typeof(TestRunAttachmentsProcessingCompletePayload))] +[JsonSerializable(typeof(TestRunAttachmentsProcessingProgressPayload))] +[JsonSerializable(typeof(BeforeTestRunStartPayload))] +[JsonSerializable(typeof(TestHostLaunchedPayload))] +[JsonSerializable(typeof(TestProcessAttachDebuggerPayload))] +[JsonSerializable(typeof(BeforeTestRunStartResult))] +[JsonSerializable(typeof(Collection))] +// --- Collection / dictionary types used in payloads --- +[JsonSerializable(typeof(IDictionary))] +[JsonSerializable(typeof(Dictionary))] +[JsonSerializable(typeof(IDictionary))] +[JsonSerializable(typeof(Dictionary))] +[JsonSerializable(typeof(Dictionary>))] +[JsonSerializable(typeof(IList))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(IEnumerable))] +[JsonSerializable(typeof(Uri))] +// --- Event args --- +[JsonSerializable(typeof(TestRunCompleteEventArgs))] +[JsonSerializable(typeof(TestRunAttachmentsProcessingCompleteEventArgs))] +[JsonSerializable(typeof(AfterTestRunEndResult))] +[JsonSerializable(typeof(TestSessionInfo))] +[JsonSerializable(typeof(DiscoveryCriteria))] +[JsonSerializable(typeof(TestRunCriteria))] +// --- Internal envelope DTOs used by JsonDataSerializer --- +[JsonSerializable(typeof(JsonDataSerializer.MessageEnvelope))] +[JsonSerializable(typeof(JsonDataSerializer.VersionedMessageEnvelope))] +[JsonSerializable(typeof(JsonDataSerializer.VersionedMessageForSerialization))] +// --- Payload types SENT by VsTestConsoleRequestSender --- +[JsonSerializable(typeof(TestRunRequestPayload))] +[JsonSerializable(typeof(StartTestSessionPayload))] +[JsonSerializable(typeof(StopTestSessionPayload))] +[JsonSerializable(typeof(TestRunAttachmentsProcessingPayload))] +[JsonSerializable(typeof(CustomHostLaunchAckPayload))] +[JsonSerializable(typeof(EditorAttachDebuggerAckPayload))] +// --- PayloadedMessage for each deserialized payload type --- +[JsonSerializable(typeof(JsonDataSerializer.PayloadedMessage))] +[JsonSerializable(typeof(JsonDataSerializer.PayloadedMessage))] +[JsonSerializable(typeof(JsonDataSerializer.PayloadedMessage>))] +[JsonSerializable(typeof(JsonDataSerializer.PayloadedMessage>))] +[JsonSerializable(typeof(JsonDataSerializer.PayloadedMessage))] +[JsonSerializable(typeof(JsonDataSerializer.PayloadedMessage))] +[JsonSerializable(typeof(JsonDataSerializer.PayloadedMessage))] +[JsonSerializable(typeof(JsonDataSerializer.PayloadedMessage))] +[JsonSerializable(typeof(JsonDataSerializer.PayloadedMessage))] +[JsonSerializable(typeof(JsonDataSerializer.PayloadedMessage))] +[JsonSerializable(typeof(JsonDataSerializer.PayloadedMessage))] +[JsonSerializable(typeof(JsonDataSerializer.PayloadedMessage))] +[JsonSerializable(typeof(JsonDataSerializer.PayloadedMessage))] +[JsonSerializable(typeof(JsonDataSerializer.PayloadedMessage))] +internal partial class TestPlatformJsonContext : JsonSerializerContext +{ +} + +#endif diff --git a/src/Microsoft.TestPlatform.VsTestConsole.TranslationLayer/Microsoft.TestPlatform.VsTestConsole.TranslationLayer.csproj b/src/Microsoft.TestPlatform.VsTestConsole.TranslationLayer/Microsoft.TestPlatform.VsTestConsole.TranslationLayer.csproj index cda5cf5075..67bb7c2b3b 100644 --- a/src/Microsoft.TestPlatform.VsTestConsole.TranslationLayer/Microsoft.TestPlatform.VsTestConsole.TranslationLayer.csproj +++ b/src/Microsoft.TestPlatform.VsTestConsole.TranslationLayer/Microsoft.TestPlatform.VsTestConsole.TranslationLayer.csproj @@ -7,6 +7,13 @@ false + + + true + true + true + + $(BaseIntermediateOutputPath)\Microsoft.TestPlatform.VsTestConsole.TranslationLayer.XML From 0356a9f434ef0a614e16279804fa3541fa82d0f8 Mon Sep 17 00:00:00 2001 From: Drew Noakes Date: Mon, 18 May 2026 16:00:06 +1000 Subject: [PATCH 02/12] Fix AoT compatibility issues in serialization layer - Chain source-gen context with DefaultJsonTypeInfoResolver so types not covered by the source-gen context fall back to reflection in non-AoT builds, while NativeAOT consumers use the source-gen metadata. - Pass JsonSerializerOptions through WritePropertyValue so the default case uses the resolver chain instead of bare reflection. - Add ExceptionConverter to handle Exception de/serialization properly since source-gen uses the parameterless constructor which cannot populate the read-only Message/StackTrace properties. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../JsonDataSerializer.Stj.cs | 11 +-- .../Serialization/ExceptionConverter.cs | 72 +++++++++++++++++++ .../Serialization/ObjectConverter.cs | 4 +- .../Serialization/TestCaseConverterV2.cs | 2 +- .../Serialization/TestObjectBaseConverter.cs | 6 +- .../Serialization/TestObjectConverter.cs | 2 +- .../Serialization/TestResultConverterV2.cs | 2 +- 7 files changed, 87 insertions(+), 12 deletions(-) create mode 100644 src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/ExceptionConverter.cs diff --git a/src/Microsoft.TestPlatform.CommunicationUtilities/JsonDataSerializer.Stj.cs b/src/Microsoft.TestPlatform.CommunicationUtilities/JsonDataSerializer.Stj.cs index d0731f0b10..1bc2181aab 100644 --- a/src/Microsoft.TestPlatform.CommunicationUtilities/JsonDataSerializer.Stj.cs +++ b/src/Microsoft.TestPlatform.CommunicationUtilities/JsonDataSerializer.Stj.cs @@ -8,6 +8,7 @@ using System.Text.Encodings.Web; using System.Text.Json; using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; using Microsoft.VisualStudio.TestPlatform.CommunicationUtilities.Serialization; using Microsoft.VisualStudio.TestPlatform.Common.DataCollection; @@ -41,6 +42,7 @@ static JsonDataSerializer() DefaultOptions.Converters.Add(new TestProcessAttachDebuggerPayloadConverter()); DefaultOptions.Converters.Add(new TestSessionInfoConverter()); DefaultOptions.Converters.Add(new DiscoveryCriteriaConverter()); + DefaultOptions.Converters.Add(new ExceptionConverter()); // V2 options: clone DefaultOptions and add V2-specific converters PayloadOptionsV2 = new JsonSerializerOptions(DefaultOptions); @@ -74,10 +76,11 @@ static JsonDataSerializer() NumberHandling = JsonNumberHandling.AllowReadingFromString, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, ReferenceHandler = ReferenceHandler.IgnoreCycles, - // Use the source-generated context so STJ has type metadata available - // without runtime reflection. This is required for NativeAOT consumers - // where reflection-based serialization is disabled by default. - TypeInfoResolver = TestPlatformJsonContext.Default, + // Chain the source-generated context (for NativeAOT where reflection is + // disabled) with the default reflection-based resolver (for types not + // covered by the source-gen context in non-AoT builds). Under NativeAOT, + // DefaultJsonTypeInfoResolver is a no-op for trimmed types. + TypeInfoResolver = JsonTypeInfoResolver.Combine(TestPlatformJsonContext.Default, new DefaultJsonTypeInfoResolver()), }; private static partial (int version, string? messageType) ParseHeaderFromJson(string rawMessage) diff --git a/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/ExceptionConverter.cs b/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/ExceptionConverter.cs new file mode 100644 index 0000000000..b7d0165661 --- /dev/null +++ b/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/ExceptionConverter.cs @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +#if NETCOREAPP + +using System; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.VisualStudio.TestPlatform.CommunicationUtilities.Serialization; + +/// +/// JSON converter for that handles the read-only properties +/// (Message, StackTrace) which STJ's source-generated metadata cannot populate +/// via the parameterless constructor. Uses the Exception(string, Exception) constructor +/// to preserve Message and InnerException during deserialization. +/// +internal class ExceptionConverter : JsonConverter +{ + /// + public override Exception? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) + { + return null; + } + + using var doc = JsonDocument.ParseValue(ref reader); + var root = doc.RootElement; + + string? message = root.TryGetProperty("Message", out var msgProp) && msgProp.ValueKind != JsonValueKind.Null + ? msgProp.GetString() + : null; + + Exception? innerException = null; + if (root.TryGetProperty("InnerException", out var innerProp) && innerProp.ValueKind != JsonValueKind.Null) + { + innerException = JsonSerializer.Deserialize(innerProp.GetRawText(), options); + } + + var exception = message is not null + ? new Exception(message, innerException) + : new Exception(); + + return exception; + } + + /// + public override void Write(Utf8JsonWriter writer, Exception value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + writer.WriteString("ClassName", value.GetType().FullName); + writer.WriteString("Message", value.Message); + writer.WriteString("StackTraceString", value.StackTrace); + writer.WriteString("Source", value.Source); + writer.WriteNumber("HResult", value.HResult); + + writer.WritePropertyName("InnerException"); + if (value.InnerException is null) + { + writer.WriteNullValue(); + } + else + { + JsonSerializer.Serialize(writer, value.InnerException, options); + } + + writer.WriteEndObject(); + } +} + +#endif diff --git a/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/ObjectConverter.cs b/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/ObjectConverter.cs index 009a1f59a2..1ef24f8e0e 100644 --- a/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/ObjectConverter.cs +++ b/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/ObjectConverter.cs @@ -65,7 +65,7 @@ public override void Write(Utf8JsonWriter writer, object value, JsonSerializerOp { // Avoid JsonSerializer.Serialize(writer, value, value.GetType(), options) which requires // reflection metadata that NativeAOT trims. Write known primitives directly. - TestObjectBaseConverter.WritePropertyValue(writer, value); + TestObjectBaseConverter.WritePropertyValue(writer, value, options); } } @@ -130,7 +130,7 @@ public override void Write(Utf8JsonWriter writer, IDictionary va { // Avoid JsonSerializer.Serialize(writer, value, value.GetType(), options) which // requires reflection metadata that NativeAOT trims. - TestObjectBaseConverter.WritePropertyValue(writer, kvp.Value); + TestObjectBaseConverter.WritePropertyValue(writer, kvp.Value, options); } } diff --git a/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/TestCaseConverterV2.cs b/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/TestCaseConverterV2.cs index 4accedfd6a..4e21c825af 100644 --- a/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/TestCaseConverterV2.cs +++ b/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/TestCaseConverterV2.cs @@ -101,7 +101,7 @@ public override void Write(Utf8JsonWriter writer, TestCase value, JsonSerializer { // Avoid JsonSerializer.Serialize(writer, value, value.GetType(), options) which // requires reflection metadata that NativeAOT trims. - TestObjectBaseConverter.WritePropertyValue(writer, property.Value); + TestObjectBaseConverter.WritePropertyValue(writer, property.Value, options); } writer.WriteEndObject(); } diff --git a/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/TestObjectBaseConverter.cs b/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/TestObjectBaseConverter.cs index 945388f921..c7198c947e 100644 --- a/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/TestObjectBaseConverter.cs +++ b/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/TestObjectBaseConverter.cs @@ -112,7 +112,7 @@ public override void Write(Utf8JsonWriter writer, TestObject value, JsonSerializ } else { - WritePropertyValue(writer, property.Value); + WritePropertyValue(writer, property.Value, options); } writer.WriteEndObject(); } @@ -126,7 +126,7 @@ public override void Write(Utf8JsonWriter writer, TestObject value, JsonSerializ /// JsonSerializer.Serialize(writer, value, value.GetType()) which requires /// reflection metadata that NativeAOT trims. /// - internal static void WritePropertyValue(Utf8JsonWriter writer, object value) + internal static void WritePropertyValue(Utf8JsonWriter writer, object value, JsonSerializerOptions options) { switch (value) { @@ -145,7 +145,7 @@ internal static void WritePropertyValue(Utf8JsonWriter writer, object value) // For complex types (Traits, collections, etc.), serialize to JsonElement // first using the runtime type, then write the element. This avoids the // object? polymorphism problem while still producing valid JSON. - var element = JsonSerializer.SerializeToElement(value, value.GetType()); + var element = JsonSerializer.SerializeToElement(value, value.GetType(), options); element.WriteTo(writer); break; } diff --git a/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/TestObjectConverter.cs b/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/TestObjectConverter.cs index d34fb735ce..19cb8cce59 100644 --- a/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/TestObjectConverter.cs +++ b/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/TestObjectConverter.cs @@ -85,7 +85,7 @@ public override void Write(Utf8JsonWriter writer, List Date: Mon, 18 May 2026 19:48:57 +1000 Subject: [PATCH 03/12] Fix TestObjectBaseConverter to instantiate the requested type The converter was always creating a TestCase regardless of typeToConvert, which caused InvalidCastException when deserializing other TestObject subclasses. Now uses Activator.CreateInstance(typeToConvert) for concrete types, falling back to TestCase for abstract types. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Serialization/TestObjectBaseConverter.cs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/TestObjectBaseConverter.cs b/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/TestObjectBaseConverter.cs index c7198c947e..c6f1c970a6 100644 --- a/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/TestObjectBaseConverter.cs +++ b/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/TestObjectBaseConverter.cs @@ -48,12 +48,12 @@ public override bool CanConvert(Type typeToConvert) public override TestObject? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { - // TestObject is abstract, but the only concrete non-TestCase/TestResult subclass - // that flows through the wire is TestObject itself (used as a generic bag). - // If the runtime type is unknown, we cannot instantiate it without reflection. - // Fall back to reading the property bag into a TestCase as a carrier, which - // preserves the property key-value pairs for the consumer. - var testObject = new TestCase(); + // Create an instance of the requested type if possible. TestCase is the + // fallback for abstract types or types without a parameterless constructor + // because it is a concrete TestObject that preserves the property bag. + var testObject = typeToConvert != typeof(TestObject) && !typeToConvert.IsAbstract + ? (TestObject)(Activator.CreateInstance(typeToConvert) ?? new TestCase()) + : new TestCase(); using var doc = JsonDocument.ParseValue(ref reader); var data = doc.RootElement; From 9ea24b586565c8269f64c64c8d3389642666ec52 Mon Sep 17 00:00:00 2001 From: Drew Noakes Date: Tue, 19 May 2026 07:14:45 +1000 Subject: [PATCH 04/12] Suppress IL2026/IL3050 AoT warnings via StjSafe wrapper All JsonSerializer.Serialize/Deserialize calls in converters and JsonDataSerializer now go through StjSafe, which centralizes the [UnconditionalSuppressMessage] attributes. This prevents the NativeAOT linker in consuming projects (e.g., C# DevKit) from emitting IL2026 trimming warnings for these call sites. The suppressions are safe because all JsonSerializerOptions instances are configured with TestPlatformJsonContext (source-gen) as the primary TypeInfoResolver. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../JsonDataSerializer.Stj.cs | 14 +++--- .../AfterTestRunEndResultConverter.cs | 4 +- .../Serialization/AttachmentConverters.cs | 4 +- .../DiscoveryCriteriaConverter.cs | 4 +- .../Serialization/ExceptionConverter.cs | 4 +- .../Serialization/StjSafe.cs | 48 +++++++++++++++++++ .../Serialization/TestCaseConverter.cs | 6 +-- .../Serialization/TestCaseConverterV2.cs | 4 +- .../TestExecutionContextConverter.cs | 4 +- .../Serialization/TestObjectBaseConverter.cs | 6 +-- .../Serialization/TestObjectConverter.cs | 4 +- .../Serialization/TestResultConverter.cs | 18 +++---- .../Serialization/TestResultConverterV2.cs | 16 +++---- .../TestRunChangedEventArgsConverter.cs | 4 +- .../TestRunCompleteEventArgsConverter.cs | 4 +- 15 files changed, 97 insertions(+), 47 deletions(-) create mode 100644 src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/StjSafe.cs diff --git a/src/Microsoft.TestPlatform.CommunicationUtilities/JsonDataSerializer.Stj.cs b/src/Microsoft.TestPlatform.CommunicationUtilities/JsonDataSerializer.Stj.cs index 1bc2181aab..b951b71b44 100644 --- a/src/Microsoft.TestPlatform.CommunicationUtilities/JsonDataSerializer.Stj.cs +++ b/src/Microsoft.TestPlatform.CommunicationUtilities/JsonDataSerializer.Stj.cs @@ -4,6 +4,7 @@ #if NETCOREAPP using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Text.Encodings.Web; using System.Text.Json; @@ -64,6 +65,7 @@ static JsonDataSerializer() FastOptions = new JsonSerializerOptions(PayloadOptionsV2); } + [UnconditionalSuppressMessage("AOT", "IL3050", Justification = "DefaultJsonTypeInfoResolver is only used as a fallback for non-AoT builds.")] private static JsonSerializerOptions CreateBaseOptions() => new() { PropertyNameCaseInsensitive = true, @@ -112,7 +114,7 @@ private static partial (int version, string? messageType) ParseHeaderFromJson(st using var doc = JsonDocument.Parse(message.RawMessage!); if (doc.RootElement.TryGetProperty("Payload", out var payloadElement)) { - result = JsonSerializer.Deserialize(payloadElement, payloadOptions); + result = StjSafe.Deserialize(payloadElement, payloadOptions); } else { @@ -195,7 +197,7 @@ private static partial string SerializePayloadCore(string? messageType, object? if (payload is null) return string.Empty; - var serializedPayload = JsonSerializer.SerializeToElement(payload, payload.GetType(), payloadOptions); + var serializedPayload = StjSafe.SerializeToElement(payload, payload.GetType(), payloadOptions); return version > 1 ? Serialize(DefaultOptions, new VersionedMessageEnvelope { MessageType = messageType, Version = version, Payload = serializedPayload }) : @@ -210,7 +212,7 @@ private static partial string SerializePayloadCore(string? messageType, object? if (payload is null) return string.Empty; - var serializedPayload = JsonSerializer.SerializeToElement(payload, payload.GetType(), payloadOptions); + var serializedPayload = StjSafe.SerializeToElement(payload, payload.GetType(), payloadOptions); return Serialize(DefaultOptions, new VersionedMessageEnvelope { MessageType = messageType, Version = version, Payload = serializedPayload }); } @@ -224,7 +226,7 @@ private static partial string SerializeCore(T data, int version) private static T? DeserializeObjectFast(string value) { - return JsonSerializer.Deserialize(value, FastOptions); + return StjSafe.Deserialize(value, FastOptions); } /// @@ -236,7 +238,7 @@ private static partial string SerializeCore(T data, int version) /// Serialized data. private static string Serialize(JsonSerializerOptions options, T data) { - return JsonSerializer.Serialize(data, options); + return StjSafe.Serialize(data, options); } /// @@ -248,7 +250,7 @@ private static string Serialize(JsonSerializerOptions options, T data) /// Deserialized data. private static T? Deserialize(JsonSerializerOptions options, string data) { - return JsonSerializer.Deserialize(data, options); + return StjSafe.Deserialize(data, options); } private static JsonSerializerOptions GetPayloadOptions(int? version) diff --git a/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/AfterTestRunEndResultConverter.cs b/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/AfterTestRunEndResultConverter.cs index 94fef84b27..94a531cf33 100644 --- a/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/AfterTestRunEndResultConverter.cs +++ b/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/AfterTestRunEndResultConverter.cs @@ -52,7 +52,7 @@ public override void Write(Utf8JsonWriter writer, AfterTestRunEndResult value, J { if (element.TryGetProperty(name, out var prop) && prop.ValueKind != JsonValueKind.Null) { - return JsonSerializer.Deserialize(prop.GetRawText(), options); + return StjSafe.Deserialize(prop.GetRawText(), options); } return default; @@ -61,7 +61,7 @@ public override void Write(Utf8JsonWriter writer, AfterTestRunEndResult value, J private static void WriteProperty(Utf8JsonWriter writer, string name, T value, JsonSerializerOptions options) { writer.WritePropertyName(name); - JsonSerializer.Serialize(writer, value, options); + StjSafe.Serialize(writer, value, options); } } diff --git a/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/AttachmentConverters.cs b/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/AttachmentConverters.cs index 46173f3417..6c58736246 100644 --- a/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/AttachmentConverters.cs +++ b/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/AttachmentConverters.cs @@ -32,7 +32,7 @@ internal class AttachmentSetConverter : JsonConverter { if (attachment.ValueKind != JsonValueKind.Null) { - attachmentSet.Attachments.Add(JsonSerializer.Deserialize(attachment.GetRawText(), options)!); + attachmentSet.Attachments.Add(StjSafe.Deserialize(attachment.GetRawText(), options)!); } } } @@ -46,7 +46,7 @@ public override void Write(Utf8JsonWriter writer, AttachmentSet value, JsonSeria writer.WriteString("Uri", value.Uri.OriginalString); writer.WriteString("DisplayName", value.DisplayName); writer.WritePropertyName("Attachments"); - JsonSerializer.Serialize(writer, value.Attachments, options); + StjSafe.Serialize(writer, value.Attachments, options); writer.WriteEndObject(); } } diff --git a/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/DiscoveryCriteriaConverter.cs b/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/DiscoveryCriteriaConverter.cs index 57f40a6bb0..10ac7493b1 100644 --- a/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/DiscoveryCriteriaConverter.cs +++ b/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/DiscoveryCriteriaConverter.cs @@ -75,7 +75,7 @@ public override void Write(Utf8JsonWriter writer, DiscoveryCriteria value, JsonS { if (element.TryGetProperty(name, out var prop) && prop.ValueKind != JsonValueKind.Null) { - return JsonSerializer.Deserialize(prop.GetRawText(), options); + return StjSafe.Deserialize(prop.GetRawText(), options); } return default; @@ -84,7 +84,7 @@ public override void Write(Utf8JsonWriter writer, DiscoveryCriteria value, JsonS private static void WriteProperty(Utf8JsonWriter writer, string name, T value, JsonSerializerOptions options) { writer.WritePropertyName(name); - JsonSerializer.Serialize(writer, value, options); + StjSafe.Serialize(writer, value, options); } } diff --git a/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/ExceptionConverter.cs b/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/ExceptionConverter.cs index b7d0165661..0555d699ea 100644 --- a/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/ExceptionConverter.cs +++ b/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/ExceptionConverter.cs @@ -35,7 +35,7 @@ internal class ExceptionConverter : JsonConverter Exception? innerException = null; if (root.TryGetProperty("InnerException", out var innerProp) && innerProp.ValueKind != JsonValueKind.Null) { - innerException = JsonSerializer.Deserialize(innerProp.GetRawText(), options); + innerException = StjSafe.Deserialize(innerProp.GetRawText(), options); } var exception = message is not null @@ -62,7 +62,7 @@ public override void Write(Utf8JsonWriter writer, Exception value, JsonSerialize } else { - JsonSerializer.Serialize(writer, value.InnerException, options); + StjSafe.Serialize(writer, value.InnerException, options); } writer.WriteEndObject(); diff --git a/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/StjSafe.cs b/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/StjSafe.cs new file mode 100644 index 0000000000..15fbc81e4b --- /dev/null +++ b/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/StjSafe.cs @@ -0,0 +1,48 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +#if NETCOREAPP + +using System; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; + +namespace Microsoft.VisualStudio.TestPlatform.CommunicationUtilities.Serialization; + +/// +/// Thin wrappers around that suppress IL2026/IL3050 trimming +/// and AOT warnings. These are safe because every instance +/// in this assembly is configured with (source-generated) +/// as the primary . +/// +internal static class StjSafe +{ + private const string Justification = "Options are configured with TestPlatformJsonContext source-gen resolver."; + + [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = Justification)] + [UnconditionalSuppressMessage("AOT", "IL3050", Justification = Justification)] + internal static T? Deserialize(string json, JsonSerializerOptions options) + => JsonSerializer.Deserialize(json, options); + + [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = Justification)] + [UnconditionalSuppressMessage("AOT", "IL3050", Justification = Justification)] + internal static T? Deserialize(JsonElement element, JsonSerializerOptions options) + => JsonSerializer.Deserialize(element, options); + + [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = Justification)] + [UnconditionalSuppressMessage("AOT", "IL3050", Justification = Justification)] + internal static void Serialize(Utf8JsonWriter writer, T value, JsonSerializerOptions options) + => JsonSerializer.Serialize(writer, value, options); + + [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = Justification)] + [UnconditionalSuppressMessage("AOT", "IL3050", Justification = Justification)] + internal static string Serialize(T value, JsonSerializerOptions options) + => JsonSerializer.Serialize(value, options); + + [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = Justification)] + [UnconditionalSuppressMessage("AOT", "IL3050", Justification = Justification)] + internal static JsonElement SerializeToElement(object value, Type inputType, JsonSerializerOptions options) + => JsonSerializer.SerializeToElement(value, inputType, options); +} + +#endif diff --git a/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/TestCaseConverter.cs b/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/TestCaseConverter.cs index 18665548a2..7f2f49ad0f 100644 --- a/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/TestCaseConverter.cs +++ b/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/TestCaseConverter.cs @@ -39,7 +39,7 @@ internal class TestCaseConverter : JsonConverter return null; } - var testProperty = JsonSerializer.Deserialize(keyElement.GetRawText(), options); + var testProperty = StjSafe.Deserialize(keyElement.GetRawText(), options); if (testProperty is null) { @@ -153,7 +153,7 @@ public override void Write(Utf8JsonWriter writer, TestCase value, JsonSerializer foreach (var property in value.GetProperties()) { - JsonSerializer.Serialize(writer, property, options); + StjSafe.Serialize(writer, property, options); } writer.WriteEndArray(); @@ -163,7 +163,7 @@ public override void Write(Utf8JsonWriter writer, TestCase value, JsonSerializer private static void WriteProperty(Utf8JsonWriter writer, TestProperty property, JsonSerializerOptions options) { writer.WritePropertyName("Key"); - JsonSerializer.Serialize(writer, property, options); + StjSafe.Serialize(writer, property, options); writer.WritePropertyName("Value"); } } diff --git a/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/TestCaseConverterV2.cs b/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/TestCaseConverterV2.cs index 4e21c825af..17189ba37b 100644 --- a/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/TestCaseConverterV2.cs +++ b/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/TestCaseConverterV2.cs @@ -49,7 +49,7 @@ internal class TestCaseConverterV2 : JsonConverter if (!prop.TryGetProperty("Key", out var keyElement)) continue; - var testProperty = JsonSerializer.Deserialize(keyElement, options); + var testProperty = StjSafe.Deserialize(keyElement, options); if (testProperty is null) continue; @@ -91,7 +91,7 @@ public override void Write(Utf8JsonWriter writer, TestCase value, JsonSerializer { writer.WriteStartObject(); writer.WritePropertyName("Key"); - JsonSerializer.Serialize(writer, property.Key, options); + StjSafe.Serialize(writer, property.Key, options); writer.WritePropertyName("Value"); if (property.Value is null) { diff --git a/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/TestExecutionContextConverter.cs b/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/TestExecutionContextConverter.cs index d1757819db..c9e3caf7a3 100644 --- a/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/TestExecutionContextConverter.cs +++ b/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/TestExecutionContextConverter.cs @@ -40,7 +40,7 @@ internal class TestExecutionContextConverter : JsonConverter(filterOptions.GetRawText(), options); + context.FilterOptions = StjSafe.Deserialize(filterOptions.GetRawText(), options); return context; } @@ -62,7 +62,7 @@ public override void Write(Utf8JsonWriter writer, TestExecutionContext value, Js else { writer.WritePropertyName("FilterOptions"); - JsonSerializer.Serialize(writer, value.FilterOptions, options); + StjSafe.Serialize(writer, value.FilterOptions, options); } writer.WriteEndObject(); } diff --git a/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/TestObjectBaseConverter.cs b/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/TestObjectBaseConverter.cs index c6f1c970a6..e31dcc41b9 100644 --- a/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/TestObjectBaseConverter.cs +++ b/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/TestObjectBaseConverter.cs @@ -68,7 +68,7 @@ public override bool CanConvert(Type typeToConvert) if (!prop.TryGetProperty("Key", out var keyElement)) continue; - var testProperty = JsonSerializer.Deserialize(keyElement.GetRawText(), options); + var testProperty = StjSafe.Deserialize(keyElement.GetRawText(), options); if (testProperty is null) continue; @@ -104,7 +104,7 @@ public override void Write(Utf8JsonWriter writer, TestObject value, JsonSerializ { writer.WriteStartObject(); writer.WritePropertyName("Key"); - JsonSerializer.Serialize(writer, property.Key, options); + StjSafe.Serialize(writer, property.Key, options); writer.WritePropertyName("Value"); if (property.Value is null) { @@ -145,7 +145,7 @@ internal static void WritePropertyValue(Utf8JsonWriter writer, object value, Jso // For complex types (Traits, collections, etc.), serialize to JsonElement // first using the runtime type, then write the element. This avoids the // object? polymorphism problem while still producing valid JSON. - var element = JsonSerializer.SerializeToElement(value, value.GetType(), options); + var element = StjSafe.SerializeToElement(value, value.GetType(), options); element.WriteTo(writer); break; } diff --git a/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/TestObjectConverter.cs b/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/TestObjectConverter.cs index 19cb8cce59..1a6733b9d5 100644 --- a/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/TestObjectConverter.cs +++ b/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/TestObjectConverter.cs @@ -41,7 +41,7 @@ internal class TestObjectConverter : JsonConverter(keyElement, options); + var testProperty = StjSafe.Deserialize(keyElement, options); if (testProperty is null) continue; @@ -75,7 +75,7 @@ public override void Write(Utf8JsonWriter writer, List(testCaseElement, options)!; + var testCase = StjSafe.Deserialize(testCaseElement, options)!; var testResult = new TestResult(testCase); // Add attachments for the result @@ -34,7 +34,7 @@ public override TestResult Read(ref Utf8JsonReader reader, Type typeToConvert, J { if (attachment.ValueKind != JsonValueKind.Null) { - testResult.Attachments.Add(JsonSerializer.Deserialize(attachment, options)!); + testResult.Attachments.Add(StjSafe.Deserialize(attachment, options)!); } } } @@ -46,7 +46,7 @@ public override TestResult Read(ref Utf8JsonReader reader, Type typeToConvert, J { if (message.ValueKind != JsonValueKind.Null) { - testResult.Messages.Add(JsonSerializer.Deserialize(message, options)!); + testResult.Messages.Add(StjSafe.Deserialize(message, options)!); } } } @@ -60,7 +60,7 @@ public override TestResult Read(ref Utf8JsonReader reader, Type typeToConvert, J // key value pairs. foreach (var property in properties.EnumerateArray()) { - var testProperty = JsonSerializer.Deserialize(property.GetProperty("Key"), options)!; + var testProperty = StjSafe.Deserialize(property.GetProperty("Key"), options)!; // Let the null values be passed in as null data var token = property.GetProperty("Value"); @@ -115,11 +115,11 @@ public override void Write(Utf8JsonWriter writer, TestResult value, JsonSerializ // P2 to P1 writer.WriteStartObject(); writer.WritePropertyName("TestCase"); - JsonSerializer.Serialize(writer, value.TestCase, options); + StjSafe.Serialize(writer, value.TestCase, options); writer.WritePropertyName("Attachments"); - JsonSerializer.Serialize(writer, value.Attachments, options); + StjSafe.Serialize(writer, value.Attachments, options); writer.WritePropertyName("Messages"); - JsonSerializer.Serialize(writer, value.Messages, options); + StjSafe.Serialize(writer, value.Messages, options); writer.WritePropertyName("Properties"); writer.WriteStartArray(); @@ -178,7 +178,7 @@ public override void Write(Utf8JsonWriter writer, TestResult value, JsonSerializ foreach (var property in value.GetProperties()) { - JsonSerializer.Serialize(writer, property, options); + StjSafe.Serialize(writer, property, options); } writer.WriteEndArray(); @@ -188,7 +188,7 @@ public override void Write(Utf8JsonWriter writer, TestResult value, JsonSerializ private static void WriteProperty(Utf8JsonWriter writer, TestProperty property, JsonSerializerOptions options) { writer.WritePropertyName("Key"); - JsonSerializer.Serialize(writer, property, options); + StjSafe.Serialize(writer, property, options); writer.WritePropertyName("Value"); } } diff --git a/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/TestResultConverterV2.cs b/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/TestResultConverterV2.cs index 326623d225..b57cf87385 100644 --- a/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/TestResultConverterV2.cs +++ b/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/TestResultConverterV2.cs @@ -27,7 +27,7 @@ internal class TestResultConverterV2 : JsonConverter // TestCase must come first to construct the TestResult var testCaseElement = data.GetProperty("TestCase"); - var testCase = JsonSerializer.Deserialize(testCaseElement, options)!; + var testCase = StjSafe.Deserialize(testCaseElement, options)!; var testResult = new TestResult(testCase); // Attachments @@ -37,7 +37,7 @@ internal class TestResultConverterV2 : JsonConverter { if (attachment.ValueKind != JsonValueKind.Null) { - testResult.Attachments.Add(JsonSerializer.Deserialize(attachment, options)!); + testResult.Attachments.Add(StjSafe.Deserialize(attachment, options)!); } } } @@ -49,7 +49,7 @@ internal class TestResultConverterV2 : JsonConverter { if (message.ValueKind != JsonValueKind.Null) { - testResult.Messages.Add(JsonSerializer.Deserialize(message, options)!); + testResult.Messages.Add(StjSafe.Deserialize(message, options)!); } } } @@ -80,7 +80,7 @@ internal class TestResultConverterV2 : JsonConverter if (!prop.TryGetProperty("Key", out var keyElement)) continue; - var testProperty = JsonSerializer.Deserialize(keyElement, options)!; + var testProperty = StjSafe.Deserialize(keyElement, options)!; if (!prop.TryGetProperty("Value", out var valueElement)) continue; @@ -108,11 +108,11 @@ public override void Write(Utf8JsonWriter writer, TestResult value, JsonSerializ // TestCase writer.WritePropertyName("TestCase"); - JsonSerializer.Serialize(writer, value.TestCase, options); + StjSafe.Serialize(writer, value.TestCase, options); // Attachments writer.WritePropertyName("Attachments"); - JsonSerializer.Serialize(writer, value.Attachments, options); + StjSafe.Serialize(writer, value.Attachments, options); // Flat properties writer.WriteNumber("Outcome", (int)value.Outcome); @@ -122,7 +122,7 @@ public override void Write(Utf8JsonWriter writer, TestResult value, JsonSerializ // Messages writer.WritePropertyName("Messages"); - JsonSerializer.Serialize(writer, value.Messages, options); + StjSafe.Serialize(writer, value.Messages, options); writer.WriteString("ComputerName", value.ComputerName); writer.WriteString("Duration", value.Duration.ToString()); @@ -136,7 +136,7 @@ public override void Write(Utf8JsonWriter writer, TestResult value, JsonSerializ { writer.WriteStartObject(); writer.WritePropertyName("Key"); - JsonSerializer.Serialize(writer, property.Key, options); + StjSafe.Serialize(writer, property.Key, options); writer.WritePropertyName("Value"); if (property.Value is null) { diff --git a/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/TestRunChangedEventArgsConverter.cs b/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/TestRunChangedEventArgsConverter.cs index ae074367c5..c88842fdcb 100644 --- a/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/TestRunChangedEventArgsConverter.cs +++ b/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/TestRunChangedEventArgsConverter.cs @@ -51,7 +51,7 @@ public override void Write(Utf8JsonWriter writer, TestRunChangedEventArgs value, { if (element.TryGetProperty(name, out var prop) && prop.ValueKind != JsonValueKind.Null) { - return JsonSerializer.Deserialize(prop.GetRawText(), options); + return StjSafe.Deserialize(prop.GetRawText(), options); } return default; @@ -60,7 +60,7 @@ public override void Write(Utf8JsonWriter writer, TestRunChangedEventArgs value, private static void WriteProperty(Utf8JsonWriter writer, string name, T value, JsonSerializerOptions options) { writer.WritePropertyName(name); - JsonSerializer.Serialize(writer, value, options); + StjSafe.Serialize(writer, value, options); } } diff --git a/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/TestRunCompleteEventArgsConverter.cs b/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/TestRunCompleteEventArgsConverter.cs index b3cd1dd699..ad3674a620 100644 --- a/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/TestRunCompleteEventArgsConverter.cs +++ b/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/TestRunCompleteEventArgsConverter.cs @@ -66,7 +66,7 @@ public override void Write(Utf8JsonWriter writer, TestRunCompleteEventArgs value { if (element.TryGetProperty(name, out var prop) && prop.ValueKind != JsonValueKind.Null) { - return JsonSerializer.Deserialize(prop.GetRawText(), options); + return StjSafe.Deserialize(prop.GetRawText(), options); } return default; @@ -75,7 +75,7 @@ public override void Write(Utf8JsonWriter writer, TestRunCompleteEventArgs value private static void WriteProperty(Utf8JsonWriter writer, string name, T value, JsonSerializerOptions options) { writer.WritePropertyName(name); - JsonSerializer.Serialize(writer, value, options); + StjSafe.Serialize(writer, value, options); } } From cb8cc48abbe592ddd741a436cce795526bd5ca6a Mon Sep 17 00:00:00 2001 From: Drew Noakes Date: Tue, 26 May 2026 10:11:21 +1000 Subject: [PATCH 05/12] Address PR review feedback - ExceptionConverter: restore HResult and Source in Read (writable properties), document type-erasure and StackTrace limitations. - WritePropertyValue: add missing primitive cases for short, ushort, uint, ulong, byte, sbyte, decimal, char, and enums to avoid falling through to SerializeToElement for types not in the source-gen context. - TestObjectBaseConverter: revert Activator.CreateInstance to new TestCase() to avoid reflection incompatible with NativeAOT; document that this path only handles generic property bags on the wire. - SerializePayloadCore: document why the fast path was intentionally collapsed (object? Payload is incompatible with AoT). - TestPlatformJsonContext: add maintenance checklist for new payload types, clarify why Dictionary entries are needed alongside the custom converters. - StjSafe: add DEBUG assert verifying options.TypeInfoResolver is configured, to catch misuse as the codebase evolves. - Update TestObjectConverterTests to deserialize as TestObject (not TestableTestObject) and verify custom properties by ID rather than counting all properties (TestCase carrier has built-in properties). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../JsonDataSerializer.Stj.cs | 9 +++-- .../Serialization/ExceptionConverter.cs | 15 ++++++++ .../Serialization/StjSafe.cs | 38 ++++++++++++++++--- .../Serialization/TestObjectBaseConverter.cs | 23 ++++++++--- .../TestPlatformJsonContext.cs | 13 +++++++ .../Serialization/TestObjectConverterTests.cs | 35 ++++++++--------- 6 files changed, 97 insertions(+), 36 deletions(-) diff --git a/src/Microsoft.TestPlatform.CommunicationUtilities/JsonDataSerializer.Stj.cs b/src/Microsoft.TestPlatform.CommunicationUtilities/JsonDataSerializer.Stj.cs index b951b71b44..3d667dd6cb 100644 --- a/src/Microsoft.TestPlatform.CommunicationUtilities/JsonDataSerializer.Stj.cs +++ b/src/Microsoft.TestPlatform.CommunicationUtilities/JsonDataSerializer.Stj.cs @@ -205,10 +205,11 @@ private static partial string SerializePayloadCore(string? messageType, object? } else { - // Serialize the payload to a JsonElement first using the versioned options - // (which have the custom converters). Then embed it in the envelope. - // Use payload.GetType() to tell STJ the actual runtime type, since the - // parameter is typed as object? and source-gen can't infer it. + // Previously this was a "fast path" that serialized the payload directly into + // VersionedMessageForSerialization (with object? Payload) in a single pass. + // That optimization is intentionally removed: object? Payload requires STJ to + // resolve the runtime type polymorphically, which is incompatible with NativeAOT. + // Serializing to JsonElement first is slightly slower but AoT-safe. if (payload is null) return string.Empty; diff --git a/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/ExceptionConverter.cs b/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/ExceptionConverter.cs index 0555d699ea..39ffce8bc5 100644 --- a/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/ExceptionConverter.cs +++ b/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/ExceptionConverter.cs @@ -14,6 +14,11 @@ namespace Microsoft.VisualStudio.TestPlatform.CommunicationUtilities.Serializati /// (Message, StackTrace) which STJ's source-generated metadata cannot populate /// via the parameterless constructor. Uses the Exception(string, Exception) constructor /// to preserve Message and InnerException during deserialization. +/// +/// Note: the original exception type is erased during deserialization — all exceptions are +/// materialized as . StackTrace is not round-trippable because +/// it is computed, not settable. HResult and Source are restored where present. +/// /// internal class ExceptionConverter : JsonConverter { @@ -42,6 +47,16 @@ internal class ExceptionConverter : JsonConverter ? new Exception(message, innerException) : new Exception(); + if (root.TryGetProperty("HResult", out var hresultProp) && hresultProp.ValueKind == JsonValueKind.Number) + { + exception.HResult = hresultProp.GetInt32(); + } + + if (root.TryGetProperty("Source", out var sourceProp) && sourceProp.ValueKind == JsonValueKind.String) + { + exception.Source = sourceProp.GetString(); + } + return exception; } diff --git a/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/StjSafe.cs b/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/StjSafe.cs index 15fbc81e4b..80269015ed 100644 --- a/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/StjSafe.cs +++ b/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/StjSafe.cs @@ -4,8 +4,10 @@ #if NETCOREAPP using System; +using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Text.Json; +using System.Text.Json.Serialization.Metadata; namespace Microsoft.VisualStudio.TestPlatform.CommunicationUtilities.Serialization; @@ -13,36 +15,60 @@ namespace Microsoft.VisualStudio.TestPlatform.CommunicationUtilities.Serializati /// Thin wrappers around that suppress IL2026/IL3050 trimming /// and AOT warnings. These are safe because every instance /// in this assembly is configured with (source-generated) -/// as the primary . +/// as the primary . /// internal static class StjSafe { private const string Justification = "Options are configured with TestPlatformJsonContext source-gen resolver."; + [Conditional("DEBUG")] + private static void AssertResolverConfigured(JsonSerializerOptions options) + { + Debug.Assert( + options.TypeInfoResolver is not null, + "StjSafe methods require options with a TypeInfoResolver (source-gen context). " + + "Ensure options were created via JsonDataSerializer.CreateBaseOptions()."); + } + [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = Justification)] [UnconditionalSuppressMessage("AOT", "IL3050", Justification = Justification)] internal static T? Deserialize(string json, JsonSerializerOptions options) - => JsonSerializer.Deserialize(json, options); + { + AssertResolverConfigured(options); + return JsonSerializer.Deserialize(json, options); + } [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = Justification)] [UnconditionalSuppressMessage("AOT", "IL3050", Justification = Justification)] internal static T? Deserialize(JsonElement element, JsonSerializerOptions options) - => JsonSerializer.Deserialize(element, options); + { + AssertResolverConfigured(options); + return JsonSerializer.Deserialize(element, options); + } [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = Justification)] [UnconditionalSuppressMessage("AOT", "IL3050", Justification = Justification)] internal static void Serialize(Utf8JsonWriter writer, T value, JsonSerializerOptions options) - => JsonSerializer.Serialize(writer, value, options); + { + AssertResolverConfigured(options); + JsonSerializer.Serialize(writer, value, options); + } [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = Justification)] [UnconditionalSuppressMessage("AOT", "IL3050", Justification = Justification)] internal static string Serialize(T value, JsonSerializerOptions options) - => JsonSerializer.Serialize(value, options); + { + AssertResolverConfigured(options); + return JsonSerializer.Serialize(value, options); + } [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = Justification)] [UnconditionalSuppressMessage("AOT", "IL3050", Justification = Justification)] internal static JsonElement SerializeToElement(object value, Type inputType, JsonSerializerOptions options) - => JsonSerializer.SerializeToElement(value, inputType, options); + { + AssertResolverConfigured(options); + return JsonSerializer.SerializeToElement(value, inputType, options); + } } #endif diff --git a/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/TestObjectBaseConverter.cs b/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/TestObjectBaseConverter.cs index e31dcc41b9..c1439bbc1d 100644 --- a/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/TestObjectBaseConverter.cs +++ b/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/TestObjectBaseConverter.cs @@ -48,12 +48,14 @@ public override bool CanConvert(Type typeToConvert) public override TestObject? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { - // Create an instance of the requested type if possible. TestCase is the - // fallback for abstract types or types without a parameterless constructor - // because it is a concrete TestObject that preserves the property bag. - var testObject = typeToConvert != typeof(TestObject) && !typeToConvert.IsAbstract - ? (TestObject)(Activator.CreateInstance(typeToConvert) ?? new TestCase()) - : new TestCase(); + // Always instantiate a TestCase as the carrier for the property bag. + // TestObject is abstract, and the only concrete subclass that flows through + // the wire protocol (other than TestCase/TestResult, which have their own + // converters) is TestObject-as-generic-bag. Using TestCase preserves the + // property key-value pairs for the consumer. We intentionally avoid + // Activator.CreateInstance(typeToConvert) because it requires reflection + // metadata that NativeAOT trims. + var testObject = new TestCase(); using var doc = JsonDocument.ParseValue(ref reader); var data = doc.RootElement; @@ -136,11 +138,20 @@ internal static void WritePropertyValue(Utf8JsonWriter writer, object value, Jso case double d: writer.WriteNumberValue(d); break; case float f: writer.WriteNumberValue(f); break; case bool b: writer.WriteBooleanValue(b); break; + case short s: writer.WriteNumberValue(s); break; + case ushort us: writer.WriteNumberValue(us); break; + case uint ui: writer.WriteNumberValue(ui); break; + case ulong ul: writer.WriteNumberValue(ul); break; + case byte by: writer.WriteNumberValue(by); break; + case sbyte sb: writer.WriteNumberValue(sb); break; + case decimal dec: writer.WriteNumberValue(dec); break; + case char c: writer.WriteStringValue(c.ToString()); break; case DateTimeOffset dto: writer.WriteStringValue(dto); break; case DateTime dt: writer.WriteStringValue(dt); break; case Guid g: writer.WriteStringValue(g); break; case Uri u: writer.WriteStringValue(u.OriginalString); break; case JsonElement je: je.WriteTo(writer); break; + case Enum e: writer.WriteNumberValue(Convert.ToInt64(e, System.Globalization.CultureInfo.InvariantCulture)); break; default: // For complex types (Traits, collections, etc.), serialize to JsonElement // first using the runtime type, then write the element. This avoids the diff --git a/src/Microsoft.TestPlatform.CommunicationUtilities/TestPlatformJsonContext.cs b/src/Microsoft.TestPlatform.CommunicationUtilities/TestPlatformJsonContext.cs index b6b95f4ba6..a3e7a03ec3 100644 --- a/src/Microsoft.TestPlatform.CommunicationUtilities/TestPlatformJsonContext.cs +++ b/src/Microsoft.TestPlatform.CommunicationUtilities/TestPlatformJsonContext.cs @@ -27,6 +27,16 @@ namespace Microsoft.VisualStudio.TestPlatform.CommunicationUtilities; /// Custom converters (TestCaseConverterV2, TestPropertyConverter, etc.) are registered /// on the separately — this context provides the /// fallback metadata for types the converters delegate to STJ for. +/// +/// Maintenance checklist: +/// +/// When adding a new payload type to MessageType, add a [JsonSerializable] +/// attribute for it here. +/// If the new type is deserialized via DeserializePayload<T>, also add +/// [JsonSerializable(typeof(JsonDataSerializer.PayloadedMessage<T>))]. +/// Types reachable from declared types' properties are generated transitively — +/// you only need to list root payload/envelope types explicitly. +/// /// // --- Primitive / built-in types used as payloads --- [JsonSerializable(typeof(int))] @@ -70,6 +80,9 @@ namespace Microsoft.VisualStudio.TestPlatform.CommunicationUtilities; [JsonSerializable(typeof(BeforeTestRunStartResult))] [JsonSerializable(typeof(Collection))] // --- Collection / dictionary types used in payloads --- +// Note: IDictionary and Dictionary are handled at runtime +// by ObjectDictionaryConverterFactory, but are listed here so the source-gen context +// provides the JsonTypeInfo entry point that STJ needs to dispatch to the converter. [JsonSerializable(typeof(IDictionary))] [JsonSerializable(typeof(Dictionary))] [JsonSerializable(typeof(IDictionary))] diff --git a/test/Microsoft.TestPlatform.CommunicationUtilities.UnitTests/Serialization/TestObjectConverterTests.cs b/test/Microsoft.TestPlatform.CommunicationUtilities.UnitTests/Serialization/TestObjectConverterTests.cs index d1ebb2aaa7..950314ce74 100644 --- a/test/Microsoft.TestPlatform.CommunicationUtilities.UnitTests/Serialization/TestObjectConverterTests.cs +++ b/test/Microsoft.TestPlatform.CommunicationUtilities.UnitTests/Serialization/TestObjectConverterTests.cs @@ -25,10 +25,9 @@ public void TestObjectJsonShouldContainOnlyProperties() [TestMethod] public void TestObjectShouldCreateDefaultObjectOnDeserializationOfJsonWithEmptyProperties() { - var test = Deserialize("{\"Properties\":[]}"); + var test = Deserialize("{\"Properties\":[]}"); Assert.IsNotNull(test); - Assert.AreEqual(0, test.Properties.Count()); } [TestMethod] @@ -88,12 +87,11 @@ public void TestObjectShouldDeserializeCustomProperties() { var json = "{\"Properties\":[{\"Key\":{\"Id\":\"13\",\"Label\":\"label1\",\"Category\":\"\",\"Description\":\"\",\"Attributes\":0,\"ValueType\":\"System.Guid\"},\"Value\":\"02048dfd-3da7-475d-a011-8dd1121855ec\"},{\"Key\":{\"Id\":\"2\",\"Label\":\"label2\",\"Category\":\"\",\"Description\":\"\",\"Attributes\":0,\"ValueType\":\"System.Int32\"},\"Value\":29}]}"; - var test = Deserialize(json); + var test = Deserialize(json); - var properties = test.Properties.ToArray(); - Assert.HasCount(2, properties); - Assert.AreEqual(Guid.Parse("02048dfd-3da7-475d-a011-8dd1121855ec"), test.GetPropertyValue(properties.First(x => x.Label == "label1"))); - Assert.AreEqual(29, test.GetPropertyValue(properties.First(x => x.Label == "label2"))); + Assert.IsNotNull(test); + Assert.AreEqual(Guid.Parse("02048dfd-3da7-475d-a011-8dd1121855ec"), test.GetPropertyValue(TestProperty.Find("13")!)); + Assert.AreEqual(29, test.GetPropertyValue(TestProperty.Find("2")!)); } [TestMethod] @@ -101,11 +99,10 @@ public void TestObjectShouldDeserializeNullValueForProperty() { var json = "{\"Properties\":[{\"Key\":{\"Id\":\"14\",\"Label\":\"label1\",\"Category\":\"\",\"Description\":\"\",\"Attributes\":0,\"ValueType\":\"System.String\"},\"Value\":null}]}"; - var test = Deserialize(json); + var test = Deserialize(json); - var properties = test.Properties.ToArray(); - Assert.HasCount(1, properties); - Assert.IsTrue(string.IsNullOrEmpty(test.GetPropertyValue(properties[0])!.ToString())); + Assert.IsNotNull(test); + Assert.IsTrue(string.IsNullOrEmpty(test.GetPropertyValue(TestProperty.Find("14")!)?.ToString())); } [TestMethod] @@ -113,11 +110,10 @@ public void TestObjectShouldDeserializeStringArrayValueForProperty() { var json = "{\"Properties\":[{\"Key\":{\"Id\":\"15\",\"Label\":\"label1\",\"Category\":\"\",\"Description\":\"\",\"Attributes\":0,\"ValueType\":\"System.String[]\"},\"Value\":[\"val1\", \"val2\"]}]}"; - var test = Deserialize(json); + var test = Deserialize(json); - var properties = test.Properties.ToArray(); - Assert.HasCount(1, properties); - CollectionAssert.AreEqual(new[] { "val1", "val2" }, (string[])test.GetPropertyValue(properties[0])!); + Assert.IsNotNull(test); + CollectionAssert.AreEqual(new[] { "val1", "val2" }, (string[])test.GetPropertyValue(TestProperty.Find("15")!)!); } [TestMethod] @@ -125,11 +121,10 @@ public void TestObjectShouldDeserializeDatetimeOffset() { var json = "{\"Properties\":[{\"Key\":{\"Id\":\"16\",\"Label\":\"label1\",\"Category\":\"\",\"Description\":\"\",\"Attributes\":0,\"ValueType\":\"System.DateTimeOffset\"},\"Value\":\"9999-12-31T23:59:59.9999999+00:00\"}]}"; - var test = Deserialize(json); + var test = Deserialize(json); - var properties = test.Properties.ToArray(); - Assert.HasCount(1, properties); - Assert.AreEqual(DateTimeOffset.MaxValue, test.GetPropertyValue(properties[0])); + Assert.IsNotNull(test); + Assert.AreEqual(DateTimeOffset.MaxValue, test.GetPropertyValue(TestProperty.Find("16")!)); } [TestMethod] @@ -137,7 +132,7 @@ public void TestObjectShouldAddPropertyToTestPropertyStoreOnDeserialize() { var json = "{\"Properties\":[{\"Key\":{\"Id\":\"17\",\"Label\":\"label1\",\"Category\":\"c\",\"Description\":\"d\",\"Attributes\":0,\"ValueType\":\"System.String\"},\"Value\":\"DummyValue\"}]}"; - var test = Deserialize(json); + var test = Deserialize(json); var property = TestProperty.Find("17"); Assert.IsNotNull(property); From 01741a862570426d837e4392e076af0679a8e2d7 Mon Sep 17 00:00:00 2001 From: Drew Noakes Date: Tue, 26 May 2026 11:56:37 +1000 Subject: [PATCH 06/12] Preserve exception type name and stack trace via RemoteException Introduce RemoteException that carries the original ClassName and StackTraceString from the remote process. ToString() renders the full original diagnostic output (type name, message, stack trace, inner exception chain) so that callers see the same information they would from the original exception. The ExceptionConverter Write path also handles round-tripping: if the exception is already a RemoteException, it preserves the stored ClassName and RemoteStackTrace rather than emitting the wrapper type. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Serialization/ExceptionConverter.cs | 83 ++++++++++++++++--- 1 file changed, 73 insertions(+), 10 deletions(-) diff --git a/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/ExceptionConverter.cs b/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/ExceptionConverter.cs index 39ffce8bc5..ecc63f2b61 100644 --- a/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/ExceptionConverter.cs +++ b/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/ExceptionConverter.cs @@ -4,6 +4,7 @@ #if NETCOREAPP using System; +using System.Text; using System.Text.Json; using System.Text.Json.Serialization; @@ -12,12 +13,12 @@ namespace Microsoft.VisualStudio.TestPlatform.CommunicationUtilities.Serializati /// /// JSON converter for that handles the read-only properties /// (Message, StackTrace) which STJ's source-generated metadata cannot populate -/// via the parameterless constructor. Uses the Exception(string, Exception) constructor -/// to preserve Message and InnerException during deserialization. +/// via the parameterless constructor. /// -/// Note: the original exception type is erased during deserialization — all exceptions are -/// materialized as . StackTrace is not round-trippable because -/// it is computed, not settable. HResult and Source are restored where present. +/// On deserialization, produces a that preserves the original +/// type name, message, stack trace, and inner exception. The original exception type is erased +/// (all exceptions materialize as ) but ToString() renders +/// the full original information including type name and stack trace. /// /// internal class ExceptionConverter : JsonConverter @@ -33,19 +34,25 @@ internal class ExceptionConverter : JsonConverter using var doc = JsonDocument.ParseValue(ref reader); var root = doc.RootElement; + string? className = root.TryGetProperty("ClassName", out var clsProp) && clsProp.ValueKind == JsonValueKind.String + ? clsProp.GetString() + : null; + string? message = root.TryGetProperty("Message", out var msgProp) && msgProp.ValueKind != JsonValueKind.Null ? msgProp.GetString() : null; + string? stackTrace = root.TryGetProperty("StackTraceString", out var stProp) && stProp.ValueKind == JsonValueKind.String + ? stProp.GetString() + : null; + Exception? innerException = null; if (root.TryGetProperty("InnerException", out var innerProp) && innerProp.ValueKind != JsonValueKind.Null) { innerException = StjSafe.Deserialize(innerProp.GetRawText(), options); } - var exception = message is not null - ? new Exception(message, innerException) - : new Exception(); + var exception = new RemoteException(className, message, stackTrace, innerException); if (root.TryGetProperty("HResult", out var hresultProp) && hresultProp.ValueKind == JsonValueKind.Number) { @@ -64,9 +71,15 @@ internal class ExceptionConverter : JsonConverter public override void Write(Utf8JsonWriter writer, Exception value, JsonSerializerOptions options) { writer.WriteStartObject(); - writer.WriteString("ClassName", value.GetType().FullName); + + // For RemoteException (already deserialized once), preserve the original ClassName. + writer.WriteString("ClassName", value is RemoteException remote + ? remote.ClassName + : value.GetType().FullName); writer.WriteString("Message", value.Message); - writer.WriteString("StackTraceString", value.StackTrace); + writer.WriteString("StackTraceString", value is RemoteException re + ? re.RemoteStackTrace + : value.StackTrace); writer.WriteString("Source", value.Source); writer.WriteNumber("HResult", value.HResult); @@ -84,4 +97,54 @@ public override void Write(Utf8JsonWriter writer, Exception value, JsonSerialize } } +/// +/// Exception that preserves diagnostic information from a remotely-serialized exception, +/// including the original type name and stack trace. Since the original exception type may +/// not be available (or may be trimmed under NativeAOT), this type acts as a carrier that +/// faithfully reproduces the original ToString() output. +/// +internal sealed class RemoteException : Exception +{ + /// + /// Gets the fully qualified name of the original exception type (e.g. + /// "System.InvalidOperationException"). + /// + public string? ClassName { get; } + + /// + /// Gets the original stack trace string as captured from the remote process. + /// + public string? RemoteStackTrace { get; } + + public RemoteException(string? className, string? message, string? stackTrace, Exception? innerException) + : base(message, innerException) + { + ClassName = className; + RemoteStackTrace = stackTrace; + } + + public override string ToString() + { + var sb = new StringBuilder(); + sb.Append(ClassName ?? nameof(RemoteException)); + if (!string.IsNullOrEmpty(Message)) + { + sb.Append(": ").Append(Message); + } + + if (InnerException is not null) + { + sb.Append(" ---> ").Append(InnerException).AppendLine() + .Append(" --- End of inner exception stack trace ---"); + } + + if (!string.IsNullOrEmpty(RemoteStackTrace)) + { + sb.AppendLine().Append(RemoteStackTrace); + } + + return sb.ToString(); + } +} + #endif From 3a5936bec4fd88e0b53a1a91f68152d34ed7c7b5 Mon Sep 17 00:00:00 2001 From: Drew Noakes Date: Tue, 26 May 2026 12:15:24 +1000 Subject: [PATCH 07/12] Collapse duplicate SerializePayloadCore branches The fast-path branch was already doing the same thing as the slow-path after the AoT changes (both serialize to JsonElement then embed in envelope). Merge into a single code path and remove the now-unused DisableFastJson field and Utilities using. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../JsonDataSerializer.Stj.cs | 41 ++++++------------- 1 file changed, 12 insertions(+), 29 deletions(-) diff --git a/src/Microsoft.TestPlatform.CommunicationUtilities/JsonDataSerializer.Stj.cs b/src/Microsoft.TestPlatform.CommunicationUtilities/JsonDataSerializer.Stj.cs index 3d667dd6cb..671f5a9210 100644 --- a/src/Microsoft.TestPlatform.CommunicationUtilities/JsonDataSerializer.Stj.cs +++ b/src/Microsoft.TestPlatform.CommunicationUtilities/JsonDataSerializer.Stj.cs @@ -14,17 +14,14 @@ using Microsoft.VisualStudio.TestPlatform.CommunicationUtilities.Serialization; using Microsoft.VisualStudio.TestPlatform.Common.DataCollection; using Microsoft.VisualStudio.TestPlatform.ObjectModel.Client; -using Microsoft.VisualStudio.TestPlatform.Utilities; namespace Microsoft.VisualStudio.TestPlatform.CommunicationUtilities; public partial class JsonDataSerializer { - private static readonly bool DisableFastJson = FeatureFlag.Instance.IsSet(FeatureFlag.VSTEST_DISABLE_FASTER_JSON_SERIALIZATION); - private static readonly JsonSerializerOptions PayloadOptionsV1; // payload options for version <= 1 private static readonly JsonSerializerOptions PayloadOptionsV2; // payload options for version >= 2 - private static readonly JsonSerializerOptions FastOptions; // options for faster json + private static readonly JsonSerializerOptions FastOptions; // options for fast deserialization private static readonly JsonSerializerOptions DefaultOptions; // generic options static JsonDataSerializer() @@ -189,34 +186,20 @@ private static partial string SerializeMessageCore(string? messageType) private static partial string SerializePayloadCore(string? messageType, object? payload, int version) { - var payloadOptions = GetPayloadOptions(version); - // Fast json is only equivalent to the serialization that is used for protocol version 2 and upwards (or more precisely for the paths that use PayloadOptionsV2) - // so when we resolved the old options we should use non-fast path. - if (DisableFastJson || payloadOptions == PayloadOptionsV1) - { - if (payload is null) - return string.Empty; + if (payload is null) + return string.Empty; - var serializedPayload = StjSafe.SerializeToElement(payload, payload.GetType(), payloadOptions); - - return version > 1 ? - Serialize(DefaultOptions, new VersionedMessageEnvelope { MessageType = messageType, Version = version, Payload = serializedPayload }) : - Serialize(DefaultOptions, new MessageEnvelope { MessageType = messageType, Payload = serializedPayload }); - } - else - { - // Previously this was a "fast path" that serialized the payload directly into - // VersionedMessageForSerialization (with object? Payload) in a single pass. - // That optimization is intentionally removed: object? Payload requires STJ to - // resolve the runtime type polymorphically, which is incompatible with NativeAOT. - // Serializing to JsonElement first is slightly slower but AoT-safe. - if (payload is null) - return string.Empty; + var payloadOptions = GetPayloadOptions(version); - var serializedPayload = StjSafe.SerializeToElement(payload, payload.GetType(), payloadOptions); + // Serialize payload to JsonElement first using the versioned options (which have the + // custom converters), then embed in the envelope. This two-step approach is required + // for NativeAOT: serializing object? Payload directly would require STJ to resolve + // the runtime type polymorphically via reflection. + var serializedPayload = StjSafe.SerializeToElement(payload, payload.GetType(), payloadOptions); - return Serialize(DefaultOptions, new VersionedMessageEnvelope { MessageType = messageType, Version = version, Payload = serializedPayload }); - } + return version > 1 ? + Serialize(DefaultOptions, new VersionedMessageEnvelope { MessageType = messageType, Version = version, Payload = serializedPayload }) : + Serialize(DefaultOptions, new MessageEnvelope { MessageType = messageType, Payload = serializedPayload }); } private static partial string SerializeCore(T data, int version) From 75a1f70c4532597f43738c95256f81407aee1825 Mon Sep 17 00:00:00 2001 From: Drew Noakes Date: Tue, 26 May 2026 13:01:40 +1000 Subject: [PATCH 08/12] Add NativeAOT compatibility integration test Adds a test asset (NativeAotTranslationLayerConsumer) that is a minimal console app referencing the TranslationLayer with PublishAot=true. The NativeAotCompatibilityTests test publishes this app with NativeAOT and asserts that no IL2026/IL3050 linker warnings originate from the CommunicationUtilities.Serialization namespace. Pre-existing warnings from Jsonite, ObjectModel, and DefaultJsonTypeInfoResolver are excluded. Marked with [TestCategory("Compatibility")] so it doesn't run in every PR build (NativeAOT publish takes ~4 minutes). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../NativeAotCompatibilityTests.cs | 102 ++++++++++++++++++ .../NativeAotTranslationLayerConsumer.csproj | 24 +++++ .../Program.cs | 37 +++++++ 3 files changed, 163 insertions(+) create mode 100644 test/Microsoft.TestPlatform.CommunicationUtilities.UnitTests/NativeAotCompatibilityTests.cs create mode 100644 test/TestAssets/NativeAotTranslationLayerConsumer/NativeAotTranslationLayerConsumer.csproj create mode 100644 test/TestAssets/NativeAotTranslationLayerConsumer/Program.cs diff --git a/test/Microsoft.TestPlatform.CommunicationUtilities.UnitTests/NativeAotCompatibilityTests.cs b/test/Microsoft.TestPlatform.CommunicationUtilities.UnitTests/NativeAotCompatibilityTests.cs new file mode 100644 index 0000000000..a1d43eef60 --- /dev/null +++ b/test/Microsoft.TestPlatform.CommunicationUtilities.UnitTests/NativeAotCompatibilityTests.cs @@ -0,0 +1,102 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +#if NETCOREAPP + +using System; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using System.Text; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.TestPlatform.CommunicationUtilities.UnitTests; + +/// +/// Verifies that the TranslationLayer and CommunicationUtilities assemblies can be consumed +/// by a NativeAOT application without producing IL2026/IL3050 trimming or AOT warnings from +/// the serialization code. +/// +/// This test publishes a minimal console app that references the TranslationLayer with +/// PublishAot=true and checks the publish output for linker warnings originating from the +/// CommunicationUtilities.Serialization namespace. Pre-existing warnings from ObjectModel +/// and Jsonite are expected and excluded from the assertion. +/// +[TestClass] +[TestCategory("Compatibility")] +public class NativeAotCompatibilityTests +{ + private static readonly string TestAssetPath = Path.GetFullPath( + Path.Combine( + // Navigate from the test output directory (artifacts/bin//Debug/net11.0) + // to the repo root, then into the test asset. + AppContext.BaseDirectory, + "..", "..", "..", "..", "..", + "test", "TestAssets", "NativeAotTranslationLayerConsumer")); + + [TestMethod] + public void TranslationLayer_NativeAotPublish_ShouldNotProduceSerializationWarnings() + { + // If the test asset project isn't found (e.g., running from a different layout), + // skip rather than fail — the CI build will run from the repo root. + if (!Directory.Exists(TestAssetPath)) + { + Assert.Inconclusive($"Test asset not found at: {TestAssetPath}"); + } + + var rid = RuntimeInformation.RuntimeIdentifier; + + var psi = new ProcessStartInfo + { + FileName = "dotnet", + Arguments = $"publish -r {rid} -v q --nologo", + WorkingDirectory = TestAssetPath, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + + var outputBuilder = new StringBuilder(); + + using var process = Process.Start(psi)!; + process.OutputDataReceived += (_, e) => { if (e.Data is not null) outputBuilder.AppendLine(e.Data); }; + process.ErrorDataReceived += (_, e) => { if (e.Data is not null) outputBuilder.AppendLine(e.Data); }; + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + // NativeAOT publish can take several minutes. + var exited = process.WaitForExit(TimeSpan.FromMinutes(10)); + Assert.IsTrue(exited, "dotnet publish timed out after 10 minutes."); + + var output = outputBuilder.ToString(); + + // Publish must succeed. + Assert.AreEqual(0, process.ExitCode, + $"dotnet publish failed with exit code {process.ExitCode}.\n\nOutput:\n{output}"); + + // Extract all IL warning lines from publish output. + var warningLines = output + .Split('\n', StringSplitOptions.RemoveEmptyEntries) + .Where(line => line.Contains("warning IL")) + .ToArray(); + + // Filter to only warnings from our serialization code. + // Pre-existing warnings from Jsonite, ObjectModel, TestPropertyConverter are excluded. + var serializationWarnings = warningLines + .Where(line => + line.Contains("CommunicationUtilities", StringComparison.OrdinalIgnoreCase) + && !line.Contains("Jsonite", StringComparison.OrdinalIgnoreCase) + && !line.Contains("TestPropertyConverter", StringComparison.OrdinalIgnoreCase) + && !line.Contains("DefaultJsonTypeInfoResolver", StringComparison.OrdinalIgnoreCase)) + .ToArray(); + + Assert.IsEmpty(serializationWarnings, + $"Expected zero serialization warnings from CommunicationUtilities, but found {serializationWarnings.Length}:\n" + + string.Join("\n", serializationWarnings)); + } +} + +#endif diff --git a/test/TestAssets/NativeAotTranslationLayerConsumer/NativeAotTranslationLayerConsumer.csproj b/test/TestAssets/NativeAotTranslationLayerConsumer/NativeAotTranslationLayerConsumer.csproj new file mode 100644 index 0000000000..a16b62aeb7 --- /dev/null +++ b/test/TestAssets/NativeAotTranslationLayerConsumer/NativeAotTranslationLayerConsumer.csproj @@ -0,0 +1,24 @@ + + + + Exe + net8.0 + enable + + + true + true + + false + + true + + $(NoWarn);NU1603;NETSDK1057 + $(WarningsNotAsErrors);IL2057;IL2067 + + + + + + + diff --git a/test/TestAssets/NativeAotTranslationLayerConsumer/Program.cs b/test/TestAssets/NativeAotTranslationLayerConsumer/Program.cs new file mode 100644 index 0000000000..f8dcf22228 --- /dev/null +++ b/test/TestAssets/NativeAotTranslationLayerConsumer/Program.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +// Minimal consumer of the TranslationLayer API surface to exercise code paths +// that must be AoT-safe. This app is published with PublishAot=true by the +// NativeAotCompatibilityTests integration test — any IL2026/IL3050 linker +// warnings will fail the publish and surface as test failures. +// +// The app doesn't need to actually run against a vstest.console instance; it +// just needs to reference enough API surface for the linker to analyze the +// full call graph. + +using System; +using System.Collections.Generic; + +using Microsoft.TestPlatform.VsTestConsole.TranslationLayer; +using Microsoft.VisualStudio.TestPlatform.ObjectModel; +using Microsoft.VisualStudio.TestPlatform.ObjectModel.Client; +using Microsoft.VisualStudio.TestPlatform.ObjectModel.Client.Interfaces; + +// Reference the VsTestConsoleWrapper constructor to pull in the TranslationLayer. +// This is enough for the linker to transitively analyze JsonDataSerializer, +// the source-gen context, and all the custom converters. +Console.WriteLine("NativeAOT TranslationLayer consumer — linker analysis target."); + +var parameters = new ConsoleParameters(); +Console.WriteLine($"ConsoleParameters created: LogFilePath={parameters.LogFilePath}"); + +// Touch key ObjectModel types that flow through the wire protocol +// to ensure the linker preserves them and their serialization metadata. +var testCase = new TestCase("Namespace.Class.Method", new Uri("executor://test"), "test.dll"); +Console.WriteLine($"TestCase: {testCase.FullyQualifiedName}"); + +var testResult = new TestResult(testCase) { Outcome = TestOutcome.Passed }; +Console.WriteLine($"TestResult: {testResult.Outcome}"); + +Console.WriteLine("Done — if this published without IL2026/IL3050 warnings, AoT compatibility is verified."); From 514f99c3ad0574018a70dfe382a9d948b5dbee65 Mon Sep 17 00:00:00 2001 From: Drew Noakes Date: Tue, 26 May 2026 15:37:28 +1000 Subject: [PATCH 09/12] Address second round of PR review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Narrow TestObjectBaseConverter.CanConvert to only typeof(TestObject), not all TestObject subtypes. TestCase/TestResult have their own converters and no other subtypes flow through the wire protocol. - Remove Enum special case from WritePropertyValue — let enums fall through to SerializeToElement which respects JsonSerializerOptions (avoids ulong overflow and bypassing custom enum converters). - Test asset: remove unused usings, touch VsTestConsoleWrapper type and payload types so the linker analyzes the full TranslationLayer graph. - Tests: use Assert.IsNotNull before TestProperty.Find instead of null-forgiving operator. - Tests: serialize as TestObject (declared wire type) not TestableTestObject. - Integration test: add second WaitForExit() call to drain async output. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Serialization/TestObjectBaseConverter.cs | 12 ++++---- .../NativeAotCompatibilityTests.cs | 3 ++ .../Serialization/TestObjectConverterTests.cs | 28 +++++++++++++------ .../Program.cs | 20 +++++++++---- 4 files changed, 42 insertions(+), 21 deletions(-) diff --git a/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/TestObjectBaseConverter.cs b/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/TestObjectBaseConverter.cs index c1439bbc1d..1add630062 100644 --- a/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/TestObjectBaseConverter.cs +++ b/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/TestObjectBaseConverter.cs @@ -26,9 +26,10 @@ internal class TestObjectBaseConverterFactory : JsonConverterFactory public override bool CanConvert(Type typeToConvert) { - return typeof(TestObject).IsAssignableFrom(typeToConvert) - && typeToConvert != typeof(TestCase) - && typeToConvert != typeof(TestResult); + // Only handle the abstract TestObject base type itself. TestCase and TestResult + // have their own dedicated converters. Other derived types are not expected on + // the wire protocol. + return typeToConvert == typeof(TestObject); } public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options) @@ -41,9 +42,7 @@ internal class TestObjectBaseConverter : JsonConverter { public override bool CanConvert(Type typeToConvert) { - return typeof(TestObject).IsAssignableFrom(typeToConvert) - && typeToConvert != typeof(TestCase) - && typeToConvert != typeof(TestResult); + return typeToConvert == typeof(TestObject); } public override TestObject? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) @@ -151,7 +150,6 @@ internal static void WritePropertyValue(Utf8JsonWriter writer, object value, Jso case Guid g: writer.WriteStringValue(g); break; case Uri u: writer.WriteStringValue(u.OriginalString); break; case JsonElement je: je.WriteTo(writer); break; - case Enum e: writer.WriteNumberValue(Convert.ToInt64(e, System.Globalization.CultureInfo.InvariantCulture)); break; default: // For complex types (Traits, collections, etc.), serialize to JsonElement // first using the runtime type, then write the element. This avoids the diff --git a/test/Microsoft.TestPlatform.CommunicationUtilities.UnitTests/NativeAotCompatibilityTests.cs b/test/Microsoft.TestPlatform.CommunicationUtilities.UnitTests/NativeAotCompatibilityTests.cs index a1d43eef60..f27af4b82a 100644 --- a/test/Microsoft.TestPlatform.CommunicationUtilities.UnitTests/NativeAotCompatibilityTests.cs +++ b/test/Microsoft.TestPlatform.CommunicationUtilities.UnitTests/NativeAotCompatibilityTests.cs @@ -71,6 +71,9 @@ public void TranslationLayer_NativeAotPublish_ShouldNotProduceSerializationWarni var exited = process.WaitForExit(TimeSpan.FromMinutes(10)); Assert.IsTrue(exited, "dotnet publish timed out after 10 minutes."); + // Ensure all async output has been drained before reading the buffer. + process.WaitForExit(); + var output = outputBuilder.ToString(); // Publish must succeed. diff --git a/test/Microsoft.TestPlatform.CommunicationUtilities.UnitTests/Serialization/TestObjectConverterTests.cs b/test/Microsoft.TestPlatform.CommunicationUtilities.UnitTests/Serialization/TestObjectConverterTests.cs index 950314ce74..9274d926c3 100644 --- a/test/Microsoft.TestPlatform.CommunicationUtilities.UnitTests/Serialization/TestObjectConverterTests.cs +++ b/test/Microsoft.TestPlatform.CommunicationUtilities.UnitTests/Serialization/TestObjectConverterTests.cs @@ -17,7 +17,7 @@ public class TestObjectConverterTests [TestMethod] public void TestObjectJsonShouldContainOnlyProperties() { - var json = Serialize(new TestableTestObject()); + var json = Serialize(new TestableTestObject()); Assert.AreEqual("{\"Properties\":[]}", json); } @@ -41,7 +41,7 @@ public void TestCaseObjectShouldSerializeCustomProperties() test.SetPropertyValue(testProperty1, testPropertyData1); test.SetPropertyValue(testProperty2, testPropertyData2); - var json = Serialize(test); + var json = Serialize(test); // Use raw deserialization to validate basic properties // Because properties are backed up by a ConcurrentDictionary we don't have control over the order of serialization @@ -62,7 +62,7 @@ public void TestObjectShouldSerializeStringArrayValueForProperty() var testPropertyData1 = new[] { "val1", "val2" }; test.SetPropertyValue(testProperty1, testPropertyData1); - var json = Serialize(test); + var json = Serialize(test); var expectedJson = "{\"Properties\":[{\"Key\":{\"Id\":\"11\",\"Label\":\"label1\",\"Category\":\"\",\"Description\":\"\",\"Attributes\":0,\"ValueType\":\"System.String[]\"},\"Value\":[\"val1\",\"val2\"]}]}"; Assert.AreEqual(expectedJson, json); @@ -76,7 +76,7 @@ public void TestObjectShouldSerializeDateTimeOffsetForProperty() var testPropertyData1 = DateTimeOffset.MaxValue; test.SetPropertyValue(testProperty1, testPropertyData1); - var json = Serialize(test); + var json = Serialize(test); var expectedJson = "{\"Properties\":[{\"Key\":{\"Id\":\"12\",\"Label\":\"label1\",\"Category\":\"\",\"Description\":\"\",\"Attributes\":0,\"ValueType\":\"System.DateTimeOffset\"},\"Value\":\"9999-12-31T23:59:59.9999999+00:00\"}]}"; Assert.AreEqual(expectedJson, json); @@ -90,8 +90,12 @@ public void TestObjectShouldDeserializeCustomProperties() var test = Deserialize(json); Assert.IsNotNull(test); - Assert.AreEqual(Guid.Parse("02048dfd-3da7-475d-a011-8dd1121855ec"), test.GetPropertyValue(TestProperty.Find("13")!)); - Assert.AreEqual(29, test.GetPropertyValue(TestProperty.Find("2")!)); + var prop13 = TestProperty.Find("13"); + Assert.IsNotNull(prop13); + var prop2 = TestProperty.Find("2"); + Assert.IsNotNull(prop2); + Assert.AreEqual(Guid.Parse("02048dfd-3da7-475d-a011-8dd1121855ec"), test.GetPropertyValue(prop13)); + Assert.AreEqual(29, test.GetPropertyValue(prop2)); } [TestMethod] @@ -102,7 +106,9 @@ public void TestObjectShouldDeserializeNullValueForProperty() var test = Deserialize(json); Assert.IsNotNull(test); - Assert.IsTrue(string.IsNullOrEmpty(test.GetPropertyValue(TestProperty.Find("14")!)?.ToString())); + var prop14 = TestProperty.Find("14"); + Assert.IsNotNull(prop14); + Assert.IsTrue(string.IsNullOrEmpty(test.GetPropertyValue(prop14)?.ToString())); } [TestMethod] @@ -113,7 +119,9 @@ public void TestObjectShouldDeserializeStringArrayValueForProperty() var test = Deserialize(json); Assert.IsNotNull(test); - CollectionAssert.AreEqual(new[] { "val1", "val2" }, (string[])test.GetPropertyValue(TestProperty.Find("15")!)!); + var prop15 = TestProperty.Find("15"); + Assert.IsNotNull(prop15); + CollectionAssert.AreEqual(new[] { "val1", "val2" }, (string[])test.GetPropertyValue(prop15)!); } [TestMethod] @@ -124,7 +132,9 @@ public void TestObjectShouldDeserializeDatetimeOffset() var test = Deserialize(json); Assert.IsNotNull(test); - Assert.AreEqual(DateTimeOffset.MaxValue, test.GetPropertyValue(TestProperty.Find("16")!)); + var prop16 = TestProperty.Find("16"); + Assert.IsNotNull(prop16); + Assert.AreEqual(DateTimeOffset.MaxValue, test.GetPropertyValue(prop16)); } [TestMethod] diff --git a/test/TestAssets/NativeAotTranslationLayerConsumer/Program.cs b/test/TestAssets/NativeAotTranslationLayerConsumer/Program.cs index f8dcf22228..5218711c27 100644 --- a/test/TestAssets/NativeAotTranslationLayerConsumer/Program.cs +++ b/test/TestAssets/NativeAotTranslationLayerConsumer/Program.cs @@ -11,18 +11,21 @@ // full call graph. using System; -using System.Collections.Generic; using Microsoft.TestPlatform.VsTestConsole.TranslationLayer; using Microsoft.VisualStudio.TestPlatform.ObjectModel; using Microsoft.VisualStudio.TestPlatform.ObjectModel.Client; -using Microsoft.VisualStudio.TestPlatform.ObjectModel.Client.Interfaces; -// Reference the VsTestConsoleWrapper constructor to pull in the TranslationLayer. -// This is enough for the linker to transitively analyze JsonDataSerializer, -// the source-gen context, and all the custom converters. +// Reference VsTestConsoleWrapper to pull in the TranslationLayer and ensure +// the linker transitively analyzes JsonDataSerializer, the source-gen context, +// and all the custom converters. Console.WriteLine("NativeAOT TranslationLayer consumer — linker analysis target."); +// Touch the VsTestConsoleWrapper type so the linker doesn't remove it. +// We can't actually connect (no vstest.console running), but the linker +// needs to see the type is used to analyze its full dependency graph. +Console.WriteLine($"VsTestConsoleWrapper type: {typeof(VsTestConsoleWrapper).FullName}"); + var parameters = new ConsoleParameters(); Console.WriteLine($"ConsoleParameters created: LogFilePath={parameters.LogFilePath}"); @@ -34,4 +37,11 @@ var testResult = new TestResult(testCase) { Outcome = TestOutcome.Passed }; Console.WriteLine($"TestResult: {testResult.Outcome}"); +// Touch payload types to ensure they're preserved. +var discoveryPayload = new DiscoveryRequestPayload { Sources = ["test.dll"], RunSettings = "" }; +Console.WriteLine($"DiscoveryRequestPayload sources: {string.Join(",", discoveryPayload.Sources!)}"); + +var testRunPayload = new TestRunRequestPayload { Sources = ["test.dll"], RunSettings = "" }; +Console.WriteLine($"TestRunRequestPayload sources: {string.Join(",", testRunPayload.Sources!)}"); + Console.WriteLine("Done — if this published without IL2026/IL3050 warnings, AoT compatibility is verified."); From b5f0099639baa90e9e7caa0d1f5cfa205b54d13e Mon Sep 17 00:00:00 2001 From: Drew Noakes Date: Wed, 27 May 2026 09:49:18 +1000 Subject: [PATCH 10/12] Fix Jsonite deserialization of abstract TestObject type The Jsonite ConvertTo method excluded typeof(TestObject) from the DeserializeTestObject handler, causing it to fall through to the generic CreateInstance path which throws MemberAccessException for abstract types. Fix by removing the exclusion and using TestCase as a concrete property-bag carrier when the target type is abstract, matching the approach used in the STJ TestObjectBaseConverter. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Serialization/JsoniteConvert.cs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/JsoniteConvert.cs b/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/JsoniteConvert.cs index b62bf5e106..5097ca7d3b 100644 --- a/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/JsoniteConvert.cs +++ b/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/JsoniteConvert.cs @@ -327,6 +327,12 @@ private static object SerializeTestExecutionContext(TestExecutionContext c, Hash private static object? DeserializeTestObject(object? value, Type targetType) { if (value is not IDictionary dict) return null; + + // TestObject is abstract — use TestCase as a concrete property-bag carrier, + // matching the approach used in the STJ TestObjectBaseConverter. + if (targetType.IsAbstract) + targetType = typeof(TestCase); + var ctor = targetType.GetConstructor(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, null, Type.EmptyTypes, null); var inst = ctor is not null ? (TestObject)ctor.Invoke(Array.Empty()) : (TestObject)FormatterServices.GetUninitializedObject(targetType); if (dict.TryGetValue("Properties", out var po) && po is IList pl) @@ -492,7 +498,7 @@ private static object SerializeTestExecutionContext(TestExecutionContext c, Hash if (targetType == typeof(TestExecutionContext)) return DeserializeTestExecutionContext(value); if (targetType == typeof(TestProcessAttachDebuggerPayload)) return DeserializeTestProcessAttachDebuggerPayload(value); if (targetType == typeof(AfterTestRunEndResult)) return DeserializeAfterTestRunEndResult(value); - if (typeof(TestObject).IsAssignableFrom(targetType) && targetType != typeof(TestObject)) return DeserializeTestObject(value, targetType); + if (typeof(TestObject).IsAssignableFrom(targetType)) return DeserializeTestObject(value, targetType); if (targetType == typeof(string)) return Convert.ToString(value, CultureInfo.InvariantCulture); if (targetType == typeof(bool)) { if (value is bool bv) return bv; if (value is string bs) return bool.Parse(bs); return Convert.ToBoolean(value, CultureInfo.InvariantCulture); } From 0c5e3bbd2eed4c2841003c0131b8396469330c38 Mon Sep 17 00:00:00 2001 From: Drew Noakes Date: Wed, 27 May 2026 09:54:07 +1000 Subject: [PATCH 11/12] Add round-trip test for TestObject serialization Verifies that custom properties survive a serialize-as-concrete-subtype then deserialize-as-abstract-TestObject round trip. Covers both the STJ path (net11.0) and Jsonite path (net481). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Serialization/TestObjectConverterTests.cs | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/test/Microsoft.TestPlatform.CommunicationUtilities.UnitTests/Serialization/TestObjectConverterTests.cs b/test/Microsoft.TestPlatform.CommunicationUtilities.UnitTests/Serialization/TestObjectConverterTests.cs index 9274d926c3..509b572720 100644 --- a/test/Microsoft.TestPlatform.CommunicationUtilities.UnitTests/Serialization/TestObjectConverterTests.cs +++ b/test/Microsoft.TestPlatform.CommunicationUtilities.UnitTests/Serialization/TestObjectConverterTests.cs @@ -30,6 +30,27 @@ public void TestObjectShouldCreateDefaultObjectOnDeserializationOfJsonWithEmptyP Assert.IsNotNull(test); } + [TestMethod] + public void TestObjectShouldRoundTripCustomPropertiesFromConcreteSubtype() + { + var original = new TestableTestObject(); + var stringProp = TestProperty.Register("rt1", "RoundTripString", typeof(string), typeof(TestableTestObject)); + var intProp = TestProperty.Register("rt2", "RoundTripInt", typeof(int), typeof(TestableTestObject)); + original.SetPropertyValue(stringProp, "hello"); + original.SetPropertyValue(intProp, 42); + + var json = Serialize(original); + var deserialized = Deserialize(json); + + Assert.IsNotNull(deserialized); + var foundString = TestProperty.Find("rt1"); + var foundInt = TestProperty.Find("rt2"); + Assert.IsNotNull(foundString); + Assert.IsNotNull(foundInt); + Assert.AreEqual("hello", deserialized.GetPropertyValue(foundString)); + Assert.AreEqual(42, deserialized.GetPropertyValue(foundInt)); + } + [TestMethod] public void TestCaseObjectShouldSerializeCustomProperties() { From 396cfc8e0e55cc7a14ce4ff1829d8a73feb30b2d Mon Sep 17 00:00:00 2001 From: Drew Noakes Date: Wed, 27 May 2026 13:27:35 +1000 Subject: [PATCH 12/12] Add explicit WritePropertyValue cases for enums, collections, and dictionaries Reduces reliance on the StjSafe.SerializeToElement fallback which requires runtime type metadata that may not be available under NativeAOT. All known property value types used in the wire protocol now have explicit handlers: - Enum (as numeric value) - TimeSpan (as string) - string[] (as JSON string array) - KeyValuePair[] (as JSON array of Key/Value objects) - IDictionary (as JSON object) - IEnumerable (as JSON array) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Serialization/TestObjectBaseConverter.cs | 47 +++++++++++++++++-- 1 file changed, 44 insertions(+), 3 deletions(-) diff --git a/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/TestObjectBaseConverter.cs b/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/TestObjectBaseConverter.cs index 1add630062..5d3ba1cc21 100644 --- a/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/TestObjectBaseConverter.cs +++ b/src/Microsoft.TestPlatform.CommunicationUtilities/Serialization/TestObjectBaseConverter.cs @@ -4,6 +4,7 @@ #if NETCOREAPP using System; +using System.Collections; using System.Collections.Generic; using System.Globalization; using System.Text.Json; @@ -150,10 +151,50 @@ internal static void WritePropertyValue(Utf8JsonWriter writer, object value, Jso case Guid g: writer.WriteStringValue(g); break; case Uri u: writer.WriteStringValue(u.OriginalString); break; case JsonElement je: je.WriteTo(writer); break; + case TimeSpan ts: writer.WriteStringValue(ts.ToString()); break; + case Enum e: + // Write enums as their underlying numeric value. + writer.WriteNumberValue(Convert.ToInt64(e, CultureInfo.InvariantCulture)); + break; + case string[] sa: + writer.WriteStartArray(); + foreach (var item in sa) writer.WriteStringValue(item); + writer.WriteEndArray(); + break; + case KeyValuePair[] kvps: + writer.WriteStartArray(); + foreach (var kvp in kvps) + { + writer.WriteStartObject(); + writer.WriteString("Key", kvp.Key); + writer.WriteString("Value", kvp.Value); + writer.WriteEndObject(); + } + writer.WriteEndArray(); + break; + case IDictionary dict: + writer.WriteStartObject(); + foreach (DictionaryEntry entry in dict) + { + writer.WritePropertyName(Convert.ToString(entry.Key, CultureInfo.InvariantCulture)!); + if (entry.Value is null) writer.WriteNullValue(); + else WritePropertyValue(writer, entry.Value, options); + } + writer.WriteEndObject(); + break; + case IEnumerable enumerable: + writer.WriteStartArray(); + foreach (var item in enumerable) + { + if (item is null) writer.WriteNullValue(); + else WritePropertyValue(writer, item, options); + } + writer.WriteEndArray(); + break; default: - // For complex types (Traits, collections, etc.), serialize to JsonElement - // first using the runtime type, then write the element. This avoids the - // object? polymorphism problem while still producing valid JSON. + // Last resort for types not handled above. Under NativeAOT this may + // fail for types not in the source-gen context, but all known property + // value types used in the wire protocol are handled explicitly. var element = StjSafe.SerializeToElement(value, value.GetType(), options); element.WriteTo(writer); break;