diff --git a/src/Platform/Microsoft.Testing.Platform/ServerMode/JsonRpc/SerializerUtilities.TestNodeSerializers.cs b/src/Platform/Microsoft.Testing.Platform/ServerMode/JsonRpc/SerializerUtilities.TestNodeSerializers.cs index b9edd462a2..8106bbb582 100644 --- a/src/Platform/Microsoft.Testing.Platform/ServerMode/JsonRpc/SerializerUtilities.TestNodeSerializers.cs +++ b/src/Platform/Microsoft.Testing.Platform/ServerMode/JsonRpc/SerializerUtilities.TestNodeSerializers.cs @@ -37,10 +37,25 @@ private static void RegisterTestNodeSerializers() }); // Serialize event types. - Serializers[typeof(TestNodeStateChangedEventArgs)] = new ObjectSerializer(ev => new Dictionary + Serializers[typeof(TestNodeStateChangedEventArgs)] = new ObjectSerializer(ev => { - [JsonRpcStrings.RunId] = ev.RunId, - [JsonRpcStrings.Changes] = ev.Changes?.Select(ch => Serialize(ch)).ToList(), + List? changes = null; + if (ev.Changes is not null) + { +#pragma warning disable IDE0028 // Collection initialization can be simplified - capacity hint is intentional. + changes = new(ev.Changes.Length); +#pragma warning restore IDE0028 + for (int i = 0; i < ev.Changes.Length; i++) + { + changes.Add(Serialize(ev.Changes[i])); + } + } + + return new Dictionary + { + [JsonRpcStrings.RunId] = ev.RunId, + [JsonRpcStrings.Changes] = changes, + }; }); Serializers[typeof(TestNode)] = new ObjectSerializer( diff --git a/src/Platform/Microsoft.Testing.Platform/ServerMode/JsonRpc/TestNodeStateChangeAggregator.cs b/src/Platform/Microsoft.Testing.Platform/ServerMode/JsonRpc/TestNodeStateChangeAggregator.cs index 4bcede3124..1e56b9ba7e 100644 --- a/src/Platform/Microsoft.Testing.Platform/ServerMode/JsonRpc/TestNodeStateChangeAggregator.cs +++ b/src/Platform/Microsoft.Testing.Platform/ServerMode/JsonRpc/TestNodeStateChangeAggregator.cs @@ -42,13 +42,20 @@ public TestNodeStateChangedEventArgs BuildAggregatedChange() TestNodeUpdateMessage[] changes = terminalTestNodeUids is null ? [.. _stateChanges] - : [.. _stateChanges.Where(stateChange => - stateChange.TestNode.Properties.SingleOrDefault() is not InProgressTestNodeStateProperty - || !terminalTestNodeUids.Contains(stateChange.TestNode.Uid))]; + : FilterStateChanges(_stateChanges, terminalTestNodeUids); return new(RunId, changes); } + // Keep the captured LINQ predicate out of BuildAggregatedChange so batches without terminal states + // do not allocate its closure. + private static TestNodeUpdateMessage[] FilterStateChanges( + List stateChanges, + HashSet terminalTestNodeUids) + => [.. stateChanges.Where(stateChange => + stateChange.TestNode.Properties.SingleOrDefault() is not InProgressTestNodeStateProperty + || !terminalTestNodeUids.Contains(stateChange.TestNode.Uid))]; + #pragma warning disable CS0618, MTP0001 private static bool IsTerminalState(TestNodeStateProperty? state) => state is PassedTestNodeStateProperty diff --git a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/ServerMode/FormatterUtilitiesTests.cs b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/ServerMode/FormatterUtilitiesTests.cs index 7fc5165fb5..fdc2d5af6b 100644 --- a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/ServerMode/FormatterUtilitiesTests.cs +++ b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/ServerMode/FormatterUtilitiesTests.cs @@ -37,6 +37,57 @@ public FormatterUtilitiesTests() Assert.AreEqual("Jsonite", _formatter.Id); #endif + [TestMethod] + public async Task Serialize_TestNodeStateChangedEventArgs_NullChanges_PreservesNull() + { + TestNodeStateChangedEventArgs message = new(Guid.Empty, null); + + IDictionary properties = SerializerUtilities.Serialize(message); + string serialized = (await _formatter.SerializeAsync(message)).Replace(" ", string.Empty); + + Assert.IsNull(properties[JsonRpcStrings.Changes]); + Assert.AreEqual("""{"runId":"00000000-0000-0000-0000-000000000000","changes":null}""", serialized); + } + + [TestMethod] + public async Task Serialize_TestNodeStateChangedEventArgs_EmptyChanges_PreservesEmptyObjectList() + { + TestNodeStateChangedEventArgs message = new(Guid.Empty, []); + + IDictionary properties = SerializerUtilities.Serialize(message); + string serialized = (await _formatter.SerializeAsync(message)).Replace(" ", string.Empty); + + List changes = Assert.IsInstanceOfType>(properties[JsonRpcStrings.Changes]); + Assert.IsEmpty(changes); + Assert.AreEqual("""{"runId":"00000000-0000-0000-0000-000000000000","changes":[]}""", serialized); + } + + [TestMethod] + public async Task Serialize_TestNodeStateChangedEventArgs_Changes_PreservesOrderObjectTypesAndSource() + { + TestNodeUpdateMessage first = CreateTestNodeUpdate("first"); + TestNodeUpdateMessage second = CreateTestNodeUpdate("second"); + TestNodeUpdateMessage[] source = [first, second]; + TestNodeStateChangedEventArgs message = new(Guid.Empty, source); + + IDictionary properties = SerializerUtilities.Serialize(message); + string serialized = (await _formatter.SerializeAsync(message)).Replace(" ", string.Empty); + + List changes = Assert.IsInstanceOfType>(properties[JsonRpcStrings.Changes]); + Assert.HasCount(2, changes); + IDictionary firstSerializedChange = Assert.IsInstanceOfType>(changes[0]); + IDictionary secondSerializedChange = Assert.IsInstanceOfType>(changes[1]); + IDictionary firstSerializedNode = Assert.IsInstanceOfType>(firstSerializedChange[JsonRpcStrings.Node]); + IDictionary secondSerializedNode = Assert.IsInstanceOfType>(secondSerializedChange[JsonRpcStrings.Node]); + Assert.AreEqual("first", firstSerializedNode[JsonRpcStrings.Uid]); + Assert.AreEqual("second", secondSerializedNode[JsonRpcStrings.Uid]); + Assert.AreSame(first, source[0]); + Assert.AreSame(second, source[1]); + Assert.AreEqual( + """{"runId":"00000000-0000-0000-0000-000000000000","changes":[{"node":{"uid":"first","display-name":"first","node-type":"group"},"parent":null},{"node":{"uid":"second","display-name":"second","node-type":"group"},"parent":null}]}""", + serialized); + } + [TestMethod] public void CanDeserializeTaskResponse() { @@ -667,6 +718,15 @@ static TestNode GetSampleTestNode() } } + private static TestNodeUpdateMessage CreateTestNodeUpdate(string uid) + => new( + default, + new TestNode + { + Uid = new TestNodeUid(uid), + DisplayName = uid, + }); + private object Deserialize(Type type, string instanceSerialized) => true switch { diff --git a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/ServerMode/TestNodeStateChangeAggregatorTests.cs b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/ServerMode/TestNodeStateChangeAggregatorTests.cs index 22d2cd322c..ee8a7247a1 100644 --- a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/ServerMode/TestNodeStateChangeAggregatorTests.cs +++ b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/ServerMode/TestNodeStateChangeAggregatorTests.cs @@ -154,6 +154,32 @@ public void BuildAggregatedChange_SeparateAggregatorBatches_DoNotInfluenceEachOt Assert.AreSame(terminal, secondAggregatedChange.Changes[0]); } + [TestMethod] + public void BuildAggregatedChange_DoesNotMutateBufferedChanges() + { + ServerTestHost.TestNodeStateChangeAggregator aggregator = new(Guid.NewGuid()); + TestNodeUpdateMessage before = CreateUpdate("before"); + TestNodeUpdateMessage inProgress = CreateUpdate("test", InProgressTestNodeStateProperty.CachedInstance); + TestNodeUpdateMessage terminal = CreateUpdate("test", PassedTestNodeStateProperty.CachedInstance); + aggregator.OnStateChange(before); + aggregator.OnStateChange(inProgress); + aggregator.OnStateChange(terminal); + + TestNodeStateChangedEventArgs firstAggregatedChange = aggregator.BuildAggregatedChange(); + terminal.TestNode.Properties._testNodeStateProperty = DiscoveredTestNodeStateProperty.CachedInstance; + TestNodeStateChangedEventArgs secondAggregatedChange = aggregator.BuildAggregatedChange(); + + Assert.IsNotNull(firstAggregatedChange.Changes); + Assert.IsNotNull(secondAggregatedChange.Changes); + Assert.HasCount(2, firstAggregatedChange.Changes); + Assert.HasCount(3, secondAggregatedChange.Changes); + Assert.AreSame(before, firstAggregatedChange.Changes[0]); + Assert.AreSame(terminal, firstAggregatedChange.Changes[1]); + Assert.AreSame(before, secondAggregatedChange.Changes[0]); + Assert.AreSame(inProgress, secondAggregatedChange.Changes[1]); + Assert.AreSame(terminal, secondAggregatedChange.Changes[2]); + } + public static IEnumerable TerminalStates() { yield return [PassedTestNodeStateProperty.CachedInstance];