diff --git a/src/modules/Elsa.Workflows.Core/Serialization/Converters/ActivityJsonConverter.cs b/src/modules/Elsa.Workflows.Core/Serialization/Converters/ActivityJsonConverter.cs index 29da39c2f..abd313c05 100644 --- a/src/modules/Elsa.Workflows.Core/Serialization/Converters/ActivityJsonConverter.cs +++ b/src/modules/Elsa.Workflows.Core/Serialization/Converters/ActivityJsonConverter.cs @@ -27,57 +27,70 @@ public class ActivityJsonConverter( if (!JsonDocument.TryParseValue(ref reader, out var doc)) throw new JsonException("Failed to parse JsonDocument"); - var activityRoot = doc.RootElement; - var activityTypeName = GetActivityDetails(activityRoot, out var activityTypeVersion, out var activityDescriptor); - var notFoundActivityTypeName = ActivityTypeNameHelper.GenerateTypeName(); - - // If the activity type is a NotFoundActivity, try to extract the original activity type name and version. - if (activityTypeName.Equals(notFoundActivityTypeName) && activityRoot.TryGetProperty("originalActivityJson", out var originalActivityJson)) + using (doc) { - activityRoot = JsonDocument.Parse(originalActivityJson.GetString()!).RootElement; - activityTypeName = GetActivityDetails(activityRoot, out activityTypeVersion, out activityDescriptor); - } + JsonDocument? originalActivityDoc = null; - var clonedOptions = GetClonedOptions(options); - // If the activity type is not found, create a NotFoundActivity instead. - if (activityDescriptor == null) - { - var notFoundActivityDescriptor = activityRegistry.Find()!; - var notFoundActivityResult = JsonActivityConstructorContextHelper.CreateActivity(notFoundActivityDescriptor, activityRoot, clonedOptions); - LogExceptionsIfAny(notFoundActivityResult); - - var notFoundActivity = notFoundActivityResult.Activity; - notFoundActivity.Type = notFoundActivityTypeName; - notFoundActivity.Version = 1; - notFoundActivity.MissingTypeName = activityTypeName; - notFoundActivity.MissingTypeVersion = activityTypeVersion; - notFoundActivity.OriginalActivityJson = activityRoot.ToString(); - - // Extract metadata from doc.RootElement rather than activityRoot. - // In round-trip scenarios, activityRoot may have been reassigned to the inner originalActivityJson (see line 37), - // but we want the metadata from the current activity being deserialized, which represents the NotFoundActivity - // placeholder's position and annotations in the designer. - if (doc.RootElement.TryGetProperty("metadata", out var outerMetadataElement)) + try { - var outerMetadata = JsonSerializer.Deserialize>(outerMetadataElement.GetRawText(), clonedOptions); - if (outerMetadata != null) + var activityRoot = doc.RootElement; + var activityTypeName = GetActivityDetails(activityRoot, out var activityTypeVersion, out var activityDescriptor); + var notFoundActivityTypeName = ActivityTypeNameHelper.GenerateTypeName(); + + // If the activity type is a NotFoundActivity, try to extract the original activity type name and version. + if (activityTypeName.Equals(notFoundActivityTypeName) && activityRoot.TryGetProperty("originalActivityJson", out var originalActivityJson)) { - notFoundActivity.Metadata = outerMetadata; + originalActivityDoc = JsonDocument.Parse(originalActivityJson.GetString()!); + activityRoot = originalActivityDoc.RootElement; + activityTypeName = GetActivityDetails(activityRoot, out activityTypeVersion, out activityDescriptor); } + + var clonedOptions = GetClonedOptions(options); + // If the activity type is not found, create a NotFoundActivity instead. + if (activityDescriptor == null) + { + var notFoundActivityDescriptor = activityRegistry.Find()!; + var notFoundActivityResult = JsonActivityConstructorContextHelper.CreateActivity(notFoundActivityDescriptor, activityRoot, clonedOptions); + LogExceptionsIfAny(notFoundActivityResult); + + var notFoundActivity = notFoundActivityResult.Activity; + notFoundActivity.Type = notFoundActivityTypeName; + notFoundActivity.Version = 1; + notFoundActivity.MissingTypeName = activityTypeName; + notFoundActivity.MissingTypeVersion = activityTypeVersion; + notFoundActivity.OriginalActivityJson = activityRoot.ToString(); + + // Extract metadata from doc.RootElement rather than activityRoot. + // In round-trip scenarios, activityRoot may have been reassigned to the inner originalActivityJson (see line 42), + // but we want the metadata from the current activity being deserialized, which represents the NotFoundActivity + // placeholder's position and annotations in the designer. + if (doc.RootElement.TryGetProperty("metadata", out var outerMetadataElement)) + { + var outerMetadata = JsonSerializer.Deserialize>(outerMetadataElement.GetRawText(), clonedOptions); + if (outerMetadata != null) + { + notFoundActivity.Metadata = outerMetadata; + } + } + + // Set display text and description after metadata assignment to ensure they always reflect the current state + notFoundActivity.SetDisplayText($"Not Found: {activityTypeName}"); + notFoundActivity.SetDescription($"Could not find activity type {activityTypeName} with version {activityTypeVersion}"); + + return notFoundActivity; + } + + var context = JsonActivityConstructorContextHelper.Create(activityDescriptor, activityRoot, clonedOptions); + var activityResult = activityDescriptor.Constructor(context); + LogExceptionsIfAny(activityResult); + + return activityResult.Activity; + } + finally + { + originalActivityDoc?.Dispose(); } - - // Set display text and description after metadata assignment to ensure they always reflect the current state - notFoundActivity.SetDisplayText($"Not Found: {activityTypeName}"); - notFoundActivity.SetDescription($"Could not find activity type {activityTypeName} with version {activityTypeVersion}"); - - return notFoundActivity; } - - var context = JsonActivityConstructorContextHelper.Create(activityDescriptor, activityRoot, clonedOptions); - var activityResult = activityDescriptor.Constructor(context); - LogExceptionsIfAny(activityResult); - - return activityResult.Activity; } void LogExceptionsIfAny(ActivityConstructionResult result) diff --git a/test/unit/Elsa.Workflows.Core.UnitTests/Serialization/Converters/ActivityJsonConverterTests.cs b/test/unit/Elsa.Workflows.Core.UnitTests/Serialization/Converters/ActivityJsonConverterTests.cs index 7782aeb13..5d088a57b 100644 --- a/test/unit/Elsa.Workflows.Core.UnitTests/Serialization/Converters/ActivityJsonConverterTests.cs +++ b/test/unit/Elsa.Workflows.Core.UnitTests/Serialization/Converters/ActivityJsonConverterTests.cs @@ -1,4 +1,4 @@ -using System.Text.Json; +using System.Text.Json; using Elsa.Common.Serialization; using Elsa.Expressions.Services; using Elsa.Workflows.Activities; @@ -63,10 +63,31 @@ public sealed class ActivityJsonConverterTests var notFoundActivity = (NotFoundActivity)result; Assert.Equal(UnknownActivityTypeName, notFoundActivity.MissingTypeName); Assert.Equal(0, notFoundActivity.MissingTypeVersion); + AssertEquivalentJson(UnknownActivityJson, notFoundActivity.OriginalActivityJson); + Assert.True(notFoundActivity.Metadata.ContainsKey("displayText")); + Assert.True(notFoundActivity.Metadata.ContainsKey("description")); + } - var expectedJsonDoc = JsonDocument.Parse(UnknownActivityJson); - var actualJsonDoc = JsonDocument.Parse(notFoundActivity.OriginalActivityJson); - Assert.Equal(expectedJsonDoc.RootElement.ToString(), actualJsonDoc.RootElement.ToString()); + [Fact] + public void When_DeserializeNestedNotFoundActivity_Then_PreservesMissingActivityBehavior() + { + // Arrange + var activityRegistry = Substitute.For(); + activityRegistry + .Find(NotFoundActivityTypeName) + .Returns(new ActivityDescriptor()); + + var sut = CreateSut(activityRegistry); + + // Act + var result = Execute(sut, NestedNotFoundActivityJson); + + // Assert + var notFoundActivity = Assert.IsType(result); + Assert.Equal(UnknownActivityTypeName, notFoundActivity.MissingTypeName); + Assert.Equal(0, notFoundActivity.MissingTypeVersion); + AssertEquivalentJson(UnknownActivityJson, notFoundActivity.OriginalActivityJson); + Assert.True(notFoundActivity.Metadata.ContainsKey("outerMarker")); Assert.True(notFoundActivity.Metadata.ContainsKey("displayText")); Assert.True(notFoundActivity.Metadata.ContainsKey("description")); } @@ -123,6 +144,13 @@ public sealed class ActivityJsonConverterTests static IActivity? Execute(ActivityJsonConverter sut, string json) => JsonSerializer.Deserialize(json, GetSerializerOptions(sut)); + static void AssertEquivalentJson(string expectedJson, string actualJson) + { + using var expectedJsonDoc = JsonDocument.Parse(expectedJson); + using var actualJsonDoc = JsonDocument.Parse(actualJson); + Assert.Equal(expectedJsonDoc.RootElement.ToString(), actualJsonDoc.RootElement.ToString()); + } + static IActivityRegistry CreateActivityRegistry(string typeName, IActivity activity, int? version = null) { var descriptor = new ActivityDescriptor { Constructor = _ => new(activity) }; @@ -177,6 +205,18 @@ public sealed class ActivityJsonConverterTests private static readonly WriteLine WriteLineActivity = new("Hello world!"); private static readonly string WriteLineActivityTypeName = ActivityTypeNameHelper.GenerateTypeName(); private static readonly string NotFoundActivityTypeName = ActivityTypeNameHelper.GenerateTypeName(); + private static readonly string NestedNotFoundActivityJson = + $$""" + { + "id": "wrapped-not-found", + "type": "{{NotFoundActivityTypeName}}", + "version": 1, + "originalActivityJson": {{JsonSerializer.Serialize(UnknownActivityJson)}}, + "metadata": { + "outerMarker": "preserved" + } + } + """; private const string WriteLineActivityJson_WithVersion = """