From 715522b2b04d5a0fac211d8f100007c99c2e2c44 Mon Sep 17 00:00:00 2001 From: Marko Lahma Date: Sun, 26 Jul 2026 20:46:02 +0300 Subject: [PATCH 1/2] fix(javascript): stop attaching Array.prototype to dictionary-like objects The custom `WrapObjectDelegate` installed by `JintJavaScriptEvaluator` duplicated what Jint already does, and got it wrong in two ways. Jint's default wrap handler is `ObjectWrapper.Create(engine, target, type)`, and `ObjectWrapper` attaches `Array.prototype` to array-like wrappers by itself when `Options.Interop.AttachArrayPrototype` is enabled (the default). Jint's own array-likeness test deliberately excludes dictionary-like types, including string-keyed generic dictionaries. The handler we installed instead: * Called `ObjectWrapper.Create(engine, target)`, dropping the declared `type` argument, so members were resolved against the runtime type rather than the declared one. * Used `ObjectArrayHelper.DetermineIfObjectIsArrayLikeClrCollection`, which only excludes the non-generic `IDictionary`. `ExpandoObject` does not implement that interface, so it came out array-like. Both the `variables` container and the `args` container are `ExpandoObject` instances, which meant `Object.getPrototypeOf(variables) === Array.prototype` was true and `variables.map`, `variables.filter`, `variables.reduce` and friends were all visible on them, with `variables.length` reporting `0` instead of `undefined`. Removing the handler restores Jint's default, which handles every case the custom one was written for: `List`, `T[]`, `HashSet`, `ImmutableArray`, `Queue` and `Stack` all still get `Array.prototype`, while dictionaries and `ExpandoObject` no longer do. `ObjectArrayHelper` is public, so it is marked obsolete rather than deleted. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0179sA2T7HuRfRfSc2JirFik --- .../Helpers/ObjectArrayHelper.cs | 1 + .../Services/JintJavaScriptEvaluator.cs | 16 ---- .../ObjectWrappingTests.cs | 79 +++++++++++++++++++ 3 files changed, 80 insertions(+), 16 deletions(-) create mode 100644 test/integration/Elsa.JavaScript.IntegrationTests/ObjectWrappingTests.cs diff --git a/src/modules/Elsa.Expressions.JavaScript/Helpers/ObjectArrayHelper.cs b/src/modules/Elsa.Expressions.JavaScript/Helpers/ObjectArrayHelper.cs index 4ad175950f..7593211c2e 100644 --- a/src/modules/Elsa.Expressions.JavaScript/Helpers/ObjectArrayHelper.cs +++ b/src/modules/Elsa.Expressions.JavaScript/Helpers/ObjectArrayHelper.cs @@ -5,6 +5,7 @@ namespace Elsa.Expressions.JavaScript.Helpers; /// /// Contains helper methods for working with object arrays. /// +[Obsolete("Jint decides array-likeness itself and attaches Array.prototype to array-like wrappers when Options.Interop.AttachArrayPrototype is enabled (the default). This helper is no longer used and will be removed in a future version.")] public static class ObjectArrayHelper { /// diff --git a/src/modules/Elsa.Expressions.JavaScript/Services/JintJavaScriptEvaluator.cs b/src/modules/Elsa.Expressions.JavaScript/Services/JintJavaScriptEvaluator.cs index 1fa60013da..ad7fa1bed7 100644 --- a/src/modules/Elsa.Expressions.JavaScript/Services/JintJavaScriptEvaluator.cs +++ b/src/modules/Elsa.Expressions.JavaScript/Services/JintJavaScriptEvaluator.cs @@ -4,13 +4,11 @@ using Elsa.Expressions.Helpers; using Elsa.Expressions.Models; using Elsa.Expressions.JavaScript.Contracts; -using Elsa.Expressions.JavaScript.Helpers; using Elsa.Expressions.JavaScript.Notifications; using Elsa.Expressions.JavaScript.ObjectConverters; using Elsa.Expressions.JavaScript.Options; using Elsa.Mediator.Contracts; using Jint; -using Jint.Runtime.Interop; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Options; @@ -70,7 +68,6 @@ private async Task GetConfiguredEngine(Action? configureEngine, engineOptions.Interop.EnumConversion = EnumConversionMode.String; ConfigureClrAccess(engineOptions); - ConfigureObjectWrapper(engineOptions); ConfigureObjectConverters(engineOptions); ConfigureExecutionConstraints(engineOptions, cancellationToken); @@ -93,19 +90,6 @@ private void ConfigureClrAccess(Jint.Options options) options.AllowClr(); } - private void ConfigureObjectWrapper(Jint.Options options) - { - options.SetWrapObjectHandler((engine, target, type) => - { - var instance = ObjectWrapper.Create(engine, target); - - if (ObjectArrayHelper.DetermineIfObjectIsArrayLikeClrCollection(target.GetType())) - instance.Prototype = engine.Intrinsics.Array.PrototypeObject; - - return instance; - }); - } - private void ConfigureExecutionConstraints(Jint.Options options, CancellationToken cancellationToken) { // An expression that never returns would otherwise occupy the calling thread forever. diff --git a/test/integration/Elsa.JavaScript.IntegrationTests/ObjectWrappingTests.cs b/test/integration/Elsa.JavaScript.IntegrationTests/ObjectWrappingTests.cs new file mode 100644 index 0000000000..136faeb975 --- /dev/null +++ b/test/integration/Elsa.JavaScript.IntegrationTests/ObjectWrappingTests.cs @@ -0,0 +1,79 @@ +using System.Dynamic; +using Elsa.Expressions.JavaScript.Contracts; +using Elsa.Expressions.Models; +using Elsa.Testing.Shared; +using Jint; +using Microsoft.Extensions.DependencyInjection; +using Xunit; +using Xunit.Abstractions; + +namespace Elsa.JavaScript.IntegrationTests; + +/// +/// Verifies how CLR objects are exposed to JavaScript: array-like collections should behave like arrays, +/// while dictionary-like objects (such as the variables and args containers) should behave +/// like plain objects. +/// +public class ObjectWrappingTests +{ + private readonly IServiceProvider _serviceProvider; + private readonly IJavaScriptEvaluator _evaluator; + + public ObjectWrappingTests(ITestOutputHelper testOutputHelper) + { + _serviceProvider = new TestApplicationBuilder(testOutputHelper).Build(); + _evaluator = _serviceProvider.GetRequiredService(); + } + + [Fact(DisplayName = "The variables container is a plain object, not an array")] + public async Task VariablesContainerIsNotArrayLike() + { + Assert.Equal("false", await EvaluateAsync("return '' + (Object.getPrototypeOf(variables) === Array.prototype);")); + Assert.Equal("undefined", await EvaluateAsync("return typeof variables.map;")); + Assert.Equal("undefined", await EvaluateAsync("return typeof variables.filter;")); + Assert.Equal("undefined", await EvaluateAsync("return typeof variables.length;")); + } + + [Fact(DisplayName = "A dictionary-like object is a plain object, not an array")] + public async Task DictionaryLikeObjectsAreNotArrayLike() + { + var expando = new ExpandoObject() as IDictionary; + expando["greeting"] = "hello"; + + Assert.Equal("undefined", await EvaluateAsync("return typeof subject.map;", engine => engine.SetValue("subject", expando))); + Assert.Equal("hello", await EvaluateAsync("return subject.greeting;", engine => engine.SetValue("subject", expando))); + Assert.Equal("undefined", await EvaluateAsync("return typeof subject.map;", engine => engine.SetValue("subject", new Dictionary { ["greeting"] = "hello" }))); + } + + [Theory(DisplayName = "Array-like CLR collections expose the array prototype")] + [InlineData("list")] + [InlineData("set")] + [InlineData("array")] + public async Task ArrayLikeCollectionsExposeArrayPrototype(string name) + { + Assert.Equal("function", await EvaluateAsync($"return typeof {name}.map;", ConfigureCollections)); + Assert.Equal("true", await EvaluateAsync($"return '' + (Object.getPrototypeOf({name}) === Array.prototype);", ConfigureCollections)); + } + + [Theory(DisplayName = "Indexable CLR collections support array iteration methods")] + [InlineData("list")] + [InlineData("array")] + public async Task IndexableCollectionsSupportArrayMethods(string name) + { + Assert.Equal("2,4,6", await EvaluateAsync($"return {name}.map(x => x * 2).join(',');", ConfigureCollections)); + Assert.Equal("6", await EvaluateAsync($"return '' + {name}.reduce((a, b) => a + b, 0);", ConfigureCollections)); + } + + private static void ConfigureCollections(Engine engine) + { + engine.SetValue("list", new List { 1, 2, 3 }); + engine.SetValue("set", new HashSet { 1, 2, 3 }); + engine.SetValue("array", new[] { 1, 2, 3 }); + } + + private async Task EvaluateAsync(string script, Action? configureEngine = null) + { + var expressionExecutionContext = new ExpressionExecutionContext(_serviceProvider, new()); + return (T?)await _evaluator.EvaluateAsync(script, typeof(T), expressionExecutionContext, configureEngine: configureEngine); + } +} From d5fa4d4eab5dabcf9127a385d34a44dd2ac7e0f9 Mon Sep 17 00:00:00 2001 From: Marko Lahma Date: Mon, 27 Jul 2026 00:36:34 +0300 Subject: [PATCH 2/2] test(javascript): cast the ExpandoObject to its dictionary interface `new ExpandoObject() as IDictionary` reads as a conversion that might fail and gives the variable a nullable declared type, when `ExpandoObject` implements the interface unconditionally. A direct cast states that, and matches the BCL's `IDictionary` annotation exactly so the value type argument lines up too. The two other `as IDictionary` uses in this test project (JintJavaScriptFunctionBehaviorTests) are deliberately left alone: there the operand is the untyped result of a script evaluation, so the `as` is a genuine type test paired with `Assert.NotNull`. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0179sA2T7HuRfRfSc2JirFik --- .../Elsa.JavaScript.IntegrationTests/ObjectWrappingTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/integration/Elsa.JavaScript.IntegrationTests/ObjectWrappingTests.cs b/test/integration/Elsa.JavaScript.IntegrationTests/ObjectWrappingTests.cs index 136faeb975..0ff4d2c158 100644 --- a/test/integration/Elsa.JavaScript.IntegrationTests/ObjectWrappingTests.cs +++ b/test/integration/Elsa.JavaScript.IntegrationTests/ObjectWrappingTests.cs @@ -37,7 +37,7 @@ public async Task VariablesContainerIsNotArrayLike() [Fact(DisplayName = "A dictionary-like object is a plain object, not an array")] public async Task DictionaryLikeObjectsAreNotArrayLike() { - var expando = new ExpandoObject() as IDictionary; + var expando = (IDictionary)new ExpandoObject(); expando["greeting"] = "hello"; Assert.Equal("undefined", await EvaluateAsync("return typeof subject.map;", engine => engine.SetValue("subject", expando)));