diff --git a/src/modules/Elsa.Expressions.JavaScript/Extensions/EngineExtensions.cs b/src/modules/Elsa.Expressions.JavaScript/Extensions/EngineExtensions.cs index 7d00527aa..1ec883a53 100644 --- a/src/modules/Elsa.Expressions.JavaScript/Extensions/EngineExtensions.cs +++ b/src/modules/Elsa.Expressions.JavaScript/Extensions/EngineExtensions.cs @@ -15,12 +15,56 @@ public static class EngineExtensions /// /// Register the specified type T with the engine. /// - public static void RegisterType(this Engine engine) => engine.SetValue(typeof(T).Name, TypeReference.CreateTypeReference(engine, typeof(T))); - + public static void RegisterType(this Engine engine) => engine.RegisterType(typeof(T)); + /// - /// Register the specified type T with the engine. + /// Register the specified type with the engine, under its type name. /// - public static void RegisterType(this Engine engine, Type type) => engine.SetValue(type.Name, TypeReference.CreateTypeReference(engine, type)); + /// + /// + /// Types whose name cannot be written as a JavaScript identifier are skipped. A constructed generic type such + /// as IDictionary<string, object> is named IDictionary`2 and an array type such as + /// byte[] is named Byte[]. Registered, those would only be reachable through bracket notation — + /// globalThis['Byte[]'] — and every constructed generic type of the same arity would claim the same + /// global, so the last one registered would silently win. + /// + /// + /// A name that is already taken is left alone. Registering the same type twice is therefore a no-op, and a + /// global the host installed — through JintOptions.RegisterType, JintOptions.ConfigureEngine or + /// the per-evaluation configureEngine callback, all of which run before the built-in registrations — + /// is never replaced. + /// + /// + public static void RegisterType(this Engine engine, Type type) + { + var name = type.Name; + + if (!IsUsableAsIdentifier(name)) + return; + + // The type registrations are contributed by several independent handlers, whose sets overlap, and they run + // after the host has configured the engine. Leaving an occupied name alone does two things: the overlapping + // types are no longer described through reflection again for every expression evaluation, and a global that + // the host - or JavaScript itself - already put there keeps its value. + if (engine.Global.HasOwnProperty(name)) + return; + + engine.SetValue(name, TypeReference.CreateTypeReference(engine, type)); + } + + private static bool IsUsableAsIdentifier(string name) + { + if (string.IsNullOrEmpty(name) || (!char.IsLetter(name[0]) && name[0] != '_' && name[0] != '$')) + return false; + + foreach (var c in name) + { + if (!char.IsLetterOrDigit(c) && c != '_' && c != '$') + return false; + } + + return true; + } internal static void SyncVariablesContainer(this Engine engine, IOptions options, string name, object? value) { @@ -32,4 +76,4 @@ public static class EngineExtensions variablesContainer[name] = ObjectConverterHelper.ProcessVariableValue(engine, value); engine.SetValue("variables", variablesContainer); } -} \ No newline at end of file +} diff --git a/test/integration/Elsa.JavaScript.IntegrationTests/TypeRegistrationTests.cs b/test/integration/Elsa.JavaScript.IntegrationTests/TypeRegistrationTests.cs new file mode 100644 index 000000000..f592c6cdd --- /dev/null +++ b/test/integration/Elsa.JavaScript.IntegrationTests/TypeRegistrationTests.cs @@ -0,0 +1,91 @@ +using Elsa.Expressions.JavaScript.Contracts; +using Elsa.Expressions.Models; +using Elsa.Extensions; +using Elsa.Testing.Shared; +using Jint; +using Jint.Native; +using Jint.Runtime.Interop; +using Microsoft.Extensions.DependencyInjection; +using Xunit; +using Xunit.Abstractions; + +namespace Elsa.JavaScript.IntegrationTests; + +/// +/// Verifies which .NET types are exposed to JavaScript and under which names. +/// +public class TypeRegistrationTests +{ + private readonly IServiceProvider _serviceProvider; + private readonly IJavaScriptEvaluator _evaluator; + + public TypeRegistrationTests(ITestOutputHelper testOutputHelper) + { + _serviceProvider = new TestApplicationBuilder(testOutputHelper).Build(); + _evaluator = _serviceProvider.GetRequiredService(); + } + + [Theory(DisplayName = "Common .NET types are available under their type name")] + [InlineData("DateTime")] + [InlineData("DateTimeOffset")] + [InlineData("TimeSpan")] + [InlineData("Guid")] + [InlineData("Random")] + [InlineData("LogPersistenceMode")] + [InlineData("ExpandoObject")] + [InlineData("JsonElement")] + [InlineData("JsonNode")] + [InlineData("JsonObject")] + [InlineData("Stream")] + public async Task CommonTypesAreRegistered(string typeName) + { + Assert.Equal("function", await EvaluateAsync($"return typeof {typeName};")); + } + + [Theory(DisplayName = "Types whose name is not a JavaScript identifier are not registered")] + [InlineData("IDictionary`2")] + [InlineData("Byte[]")] + public async Task TypesWithUnusableNamesAreNotRegistered(string globalName) + { + Assert.Equal("undefined", await EvaluateAsync($"return typeof globalThis['{globalName}'];")); + } + + [Fact(DisplayName = "A type registered by the host stays available after the built-in registrations run")] + public async Task HostRegisteredTypesSurviveTheBuiltInRegistrations() + { + Assert.Equal("function", await EvaluateAsync("return typeof Uri;", engine => engine.RegisterType())); + } + + [Fact(DisplayName = "A global installed by the host wins over the built-in registration of the same name")] + public async Task HostGlobalsAreNotOverwrittenByTheBuiltInRegistrations() + { + // Guid is registered by both ConfigureEngineWithCommonTypes and the default variable descriptor set, and + // those handlers run after the configureEngine callback. The host's value has to survive them. + Assert.Equal("host-provided", await EvaluateAsync("return Guid;", engine => engine.SetValue("Guid", "host-provided"))); + } + + [Fact(DisplayName = "A registered type is exposed as a CLR type reference and registering it again is a no-op")] + public async Task RegisterTypeInstallsATypeReferenceAndIsIdempotent() + { + JsValue afterFirstRegistration = JsValue.Undefined; + JsValue afterSecondRegistration = JsValue.Undefined; + + await EvaluateAsync("return 'done';", engine => + { + engine.RegisterType(); + afterFirstRegistration = engine.GetValue("Uri"); + engine.RegisterType(); + afterSecondRegistration = engine.GetValue("Uri"); + }); + + var typeReference = Assert.IsType(afterFirstRegistration); + Assert.Equal(typeof(Uri), typeReference.ReferenceType); + Assert.Same(afterFirstRegistration, afterSecondRegistration); + } + + private async Task EvaluateAsync(string script, Action? configureEngine = null) + { + var context = new ExpressionExecutionContext(_serviceProvider, new()); + return (string?)await _evaluator.EvaluateAsync(script, typeof(string), context, configureEngine: configureEngine); + } +}