diff --git a/src/Take.Blip.Builder.Benchmark/Actions/ExecuteScriptBenchmarkTests.cs b/src/Take.Blip.Builder.Benchmark/Actions/ExecuteScriptBenchmarkTests.cs new file mode 100644 index 00000000..91896f7d --- /dev/null +++ b/src/Take.Blip.Builder.Benchmark/Actions/ExecuteScriptBenchmarkTests.cs @@ -0,0 +1,141 @@ +using System; +using System.Threading.Tasks; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Order; +using NSubstitute; +using Serilog; +using Take.Blip.Builder.Actions.ExecuteScript; +using Take.Blip.Builder.Actions.ExecuteScriptV2; +using Take.Blip.Builder.Benchmark.Context; +using Take.Blip.Builder.Hosting; +using Take.Blip.Builder.Utils; + +namespace Take.Blip.Builder.Benchmark.Actions +{ + /// + [MemoryDiagnoser] + [Orderer(SummaryOrderPolicy.FastestToSlowest)] + public class ExecuteScriptBenchmarkTests : ActionTestsBase + { + private ExecuteScriptV2Action _v2Action; + private ExecuteScriptAction _v1Action; + + /// + /// Setup the benchmark tests. + /// + [GlobalSetup] + public void Setup() + { + var configuration = new TestConfiguration(); + var conventions = new ConventionsConfiguration(); + + configuration.ExecuteScriptV2Timeout = TimeSpan.FromMilliseconds(300); + configuration.ExecuteScriptV2MaxRuntimeHeapSize = + conventions.ExecuteScriptV2MaxRuntimeHeapSize; + configuration.ExecuteScriptV2MaxRuntimeStackUsage = + conventions.ExecuteScriptV2MaxRuntimeStackUsage; + + configuration.ExecuteScriptTimeout = TimeSpan.FromMilliseconds(300); + configuration.ExecuteScriptLimitMemory = conventions.ExecuteScriptLimitMemory; + configuration.ExecuteScriptLimitRecursion = 100000; + configuration.ExecuteScriptMaxStatements = 0; + + _v2Action = new ExecuteScriptV2Action(configuration, Substitute.For(), + Substitute.For()); + + _v1Action = new ExecuteScriptAction(configuration, Substitute.For()); + } + + /// + /// Execute a loop script using the V1 action. + /// + [Benchmark] + public async Task ExecuteScriptV1LoopScript() + { + await _v1Action.ExecuteAsync(Context, Settings._v1LoopSettings, CancellationToken); + } + + /// + /// Execute a loop script using the V2 action. + /// + [Benchmark] + public async Task ExecuteScriptV2LoopScript() + { + await _v2Action.ExecuteAsync(Context, Settings._v2LoopSettings, CancellationToken); + } + + /// + /// Execute a math script using the V1 action. + /// + [Benchmark] + public async Task ExecuteScriptV1MathScript() + { + await _v1Action.ExecuteAsync(Context, Settings._v1MathSettings, CancellationToken); + } + + /// + /// Execute a math script using the V2 action. + /// + [Benchmark] + public async Task ExecuteScriptV2MathScript() + { + await _v2Action.ExecuteAsync(Context, Settings._v2MathSettings, CancellationToken); + } + + /// + /// Execute a json script using the V1 action. + /// + [Benchmark] + public async Task ExecuteScriptV1JsonScript() + { + await _v1Action.ExecuteAsync(Context, Settings._v1JsonSettings, CancellationToken); + } + + /// + /// Execute a json script using the V2 action. + /// + [Benchmark] + public async Task ExecuteScriptV2JsonScript() + { + await _v2Action.ExecuteAsync(Context, Settings._v2JsonSettings, CancellationToken); + } + + /// + /// Execute a simple script using the V1 action. + /// + [Benchmark] + public async Task ExecuteScriptV1SimpleScript() + { + await _v1Action.ExecuteAsync(Context, Settings._v1SimpleSettings, CancellationToken); + } + + /// + /// Execute a simple script using the V2 action. + /// + [Benchmark] + public async Task ExecuteScriptV2SimpleScript() + { + await _v2Action.ExecuteAsync(Context, Settings._v2SimpleSettings, CancellationToken); + } + + /// + /// Execute a recursion loop script using the V1 action. + /// + [Benchmark] + public async Task ExecuteScriptV1RecursionLoopScript() + { + await _v1Action.ExecuteAsync(Context, Settings._v1RecursionLoopSettings, + CancellationToken); + } + + /// + /// Execute a recursion loop script using the V2 action. + /// + [Benchmark] + public async Task ExecuteScriptV2RecursionLoopScript() + { + await _v2Action.ExecuteAsync(Context, Settings._v2RecursionLoopSettings, + CancellationToken); + } + } +} \ No newline at end of file diff --git a/src/Take.Blip.Builder.Benchmark/Actions/Settings.cs b/src/Take.Blip.Builder.Benchmark/Actions/Settings.cs new file mode 100644 index 00000000..7c710355 --- /dev/null +++ b/src/Take.Blip.Builder.Benchmark/Actions/Settings.cs @@ -0,0 +1,157 @@ +using Newtonsoft.Json.Linq; +using Take.Blip.Builder.Actions.ExecuteScript; +using Take.Blip.Builder.Actions.ExecuteScriptV2; + +namespace Take.Blip.Builder.Benchmark.Actions; + +internal static class Settings +{ + private const string SIMPLE_SCRIPT = """ + function run() { + return 'a'; + } + """; + + private const string LOOP_SCRIPT = """ + function run() { + let a = 0; + + while (a < 1000) { + a++; + } + + return a; + } + """; + + private const string RECURSION_LOOP_SCRIPT = """ + function recursiveLoop(a) { + if (a < 1000) { + return recursiveLoop(a + 1); + } + return a; + } + + function run() { + return recursiveLoop(0); + } + """; + + private const string JSON_SCRIPT = """ + function run() { + const json = { 'a': 1, 'b': 'c', 'd' : ['1', '2'], 'e': { 'f': 1 } }; + + let stringJson = JSON.stringify(json); + + let parsedJson = JSON.parse(stringJson); + + return parsedJson.a + parsedJson.e.f; + } + """; + + private const string MATH_SCRIPT = """ + function add(a, b) { + return a + b; + } + + function subtract(a, b) { + return a - b; + } + + function multiply(a, b) { + return a * b; + } + + function divide(a, b) { + if (b == 0) { + throw 'Division by zero'; + } + return a / b; + } + + function calculate(a, b) { + let sum = add(a, b); + let difference = subtract(a, b); + let product = multiply(a, b); + let quotient = divide(a, b); + + return { + 'sum': sum, + 'difference': difference, + 'product': product, + 'quotient': quotient + }; + } + + function run() { + let a = 10; + let b = 2; + + let result = calculate(a, b); + + let finalResult = result.sum * result.difference / result.quotient; + + return finalResult; + } + """; + + internal static readonly JObject _v1LoopSettings = JObject.FromObject( + new ExecuteScriptSettings + { + OutputVariable = "result", Function = "run", Source = LOOP_SCRIPT + }); + + internal static readonly JObject _v2LoopSettings = JObject.FromObject( + new ExecuteScriptV2Settings + { + OutputVariable = "result", Function = "run", Source = LOOP_SCRIPT + }); + + internal static readonly JObject _v1MathSettings = JObject.FromObject( + new ExecuteScriptSettings + { + OutputVariable = "result", Function = "run", Source = MATH_SCRIPT + }); + + internal static readonly JObject _v2MathSettings = JObject.FromObject( + new ExecuteScriptV2Settings + { + OutputVariable = "result", Function = "run", Source = MATH_SCRIPT + }); + + internal static readonly JObject _v1JsonSettings = JObject.FromObject( + new ExecuteScriptSettings + { + OutputVariable = "result", Function = "run", Source = JSON_SCRIPT + }); + + internal static readonly JObject _v2JsonSettings = JObject.FromObject( + new ExecuteScriptV2Settings + { + OutputVariable = "result", Function = "run", Source = JSON_SCRIPT + }); + + internal static readonly JObject _v1SimpleSettings = JObject.FromObject( + new ExecuteScriptSettings + { + OutputVariable = "result", Function = "run", Source = SIMPLE_SCRIPT + }); + + internal static readonly JObject _v2SimpleSettings = JObject.FromObject( + new ExecuteScriptV2Settings + { + OutputVariable = "result", Function = "run", Source = SIMPLE_SCRIPT + }); + + internal static readonly JObject _v1RecursionLoopSettings = JObject.FromObject( + new ExecuteScriptSettings + { + OutputVariable = "result", Function = "run", Source = RECURSION_LOOP_SCRIPT + }); + + internal static readonly JObject _v2RecursionLoopSettings = JObject.FromObject( + new ExecuteScriptV2Settings + { + OutputVariable = "result", Function = "run", Source = RECURSION_LOOP_SCRIPT + }); +} \ No newline at end of file diff --git a/src/Take.Blip.Builder.Benchmark/Actions/TestConfiguration.cs b/src/Take.Blip.Builder.Benchmark/Actions/TestConfiguration.cs new file mode 100644 index 00000000..7e64b350 --- /dev/null +++ b/src/Take.Blip.Builder.Benchmark/Actions/TestConfiguration.cs @@ -0,0 +1,32 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using Take.Blip.Builder.Hosting; +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member + +namespace Take.Blip.Builder.Benchmark.Actions +{ + /// + [SuppressMessage("ReSharper", "UnusedAutoPropertyAccessor.Local")] + [SuppressMessage("ReSharper", "UnusedAutoPropertyAccessor.Global")] + public class TestConfiguration : IConfiguration + { + public TimeSpan InputProcessingTimeout { get; set; } + public int RedisDatabase { get; set; } + public string RedisKeyPrefix { get; set; } + public string InternalUris { get; set; } + public int MaxTransitionsByInput { get; set; } + public int TraceQueueBoundedCapacity { get; set; } + public int TraceQueueMaxDegreeOfParallelism { get; set; } + public TimeSpan TraceTimeout { get; set; } + public TimeSpan DefaultActionExecutionTimeout { get; set; } + public int ExecuteScriptLimitRecursion { get; set; } + public int ExecuteScriptMaxStatements { get; set; } + public long ExecuteScriptLimitMemory { get; set; } + public long ExecuteScriptLimitMemoryWarning { get; set; } + public TimeSpan ExecuteScriptTimeout { get; set; } + public TimeSpan ExecuteScriptV2Timeout { get; set; } + public int MaximumInputExpirationLoop { get; set; } + public long ExecuteScriptV2MaxRuntimeHeapSize { get; set; } + public long ExecuteScriptV2MaxRuntimeStackUsage { get; set; } + } +} \ No newline at end of file diff --git a/src/Take.Blip.Builder.Benchmark/Context/ActionTestsBase.cs b/src/Take.Blip.Builder.Benchmark/Context/ActionTestsBase.cs new file mode 100644 index 00000000..81a00a74 --- /dev/null +++ b/src/Take.Blip.Builder.Benchmark/Context/ActionTestsBase.cs @@ -0,0 +1,43 @@ +using Lime.Messaging.Contents; +using Lime.Protocol; +using Lime.Protocol.Serialization; +using Lime.Protocol.Serialization.Newtonsoft; +using NSubstitute; +using Take.Blip.Builder.Models; + +namespace Take.Blip.Builder.Benchmark.Context; + +/// +public class ActionTestsBase : ContextTestsBase +{ + /// + protected ActionTestsBase() + { + Context.Flow.Returns(Flow); + From = UserIdentity.ToNode(); + To = OwnerIdentity.ToNode(); + Message = new Message() + { + From = From, To = To, Content = new PlainText { Text = "Hello BLiP" } + }; + Input = new LazyInput( + Message, + UserIdentity, + new BuilderConfiguration(), + new DocumentSerializer(new DocumentTypeResolver()), + new EnvelopeSerializer(new DocumentTypeResolver()), + null, + CancellationToken); + Context.Input.Returns(Input); + Context.OwnerIdentity.Returns(OwnerIdentity); + Context.UserIdentity.Returns(UserIdentity); + } + + private Node From { get; } + + private Node To { get; } + + private Message Message { get; } + + private LazyInput Input { get; } +} \ No newline at end of file diff --git a/src/Take.Blip.Builder.Benchmark/Context/CancellationTokenTestsBase.cs b/src/Take.Blip.Builder.Benchmark/Context/CancellationTokenTestsBase.cs new file mode 100644 index 00000000..adbbab96 --- /dev/null +++ b/src/Take.Blip.Builder.Benchmark/Context/CancellationTokenTestsBase.cs @@ -0,0 +1,39 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Threading; + +namespace Take.Blip.Builder.Benchmark.Context; + +/// +public class CancellationTokenTestsBase : IDisposable +{ + /// + /// The cancellation token. + /// + protected CancellationToken CancellationToken => CancellationTokenSource.Token; + + private CancellationTokenSource CancellationTokenSource { get; set; } = + new(TimeSpan.FromSeconds(30)); + + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Dispose the resources. + /// + /// + [SuppressMessage("ReSharper", "VirtualMemberNeverOverridden.Global")] + protected virtual void Dispose(bool disposing) + { + // Cleanup + + if (disposing) + { + CancellationTokenSource?.Dispose(); + } + } +} \ No newline at end of file diff --git a/src/Take.Blip.Builder.Benchmark/Context/ContextTestsBase.cs b/src/Take.Blip.Builder.Benchmark/Context/ContextTestsBase.cs new file mode 100644 index 00000000..69de5467 --- /dev/null +++ b/src/Take.Blip.Builder.Benchmark/Context/ContextTestsBase.cs @@ -0,0 +1,43 @@ +using System; +using System.Collections.Generic; +using Lime.Protocol; +using NSubstitute; +using Take.Blip.Builder.Models; + +namespace Take.Blip.Builder.Benchmark.Context; + +/// +public class ContextTestsBase : CancellationTokenTestsBase +{ + /// + protected ContextTestsBase() + { + Context = Substitute.For(); + Flow = new Flow { Configuration = new Dictionary() }; + UserIdentity = new Identity(Guid.NewGuid().ToString(), "msging.net"); + OwnerIdentity = new Identity("application", "msging.net"); + Context.Flow.Returns(Flow); + Context.UserIdentity.Returns(UserIdentity); + Context.OwnerIdentity.Returns(OwnerIdentity); + } + + /// + /// The context instance. + /// + protected IContext Context { get; set; } + + /// + /// The flow instance. + /// + protected Flow Flow { get; } + + /// + /// The user identity. + /// + protected Identity UserIdentity { get; } + + /// + /// The owner identity. + /// + protected Identity OwnerIdentity { get; } +} \ No newline at end of file diff --git a/src/Take.Blip.Builder.Benchmark/Program.cs b/src/Take.Blip.Builder.Benchmark/Program.cs new file mode 100644 index 00000000..778523b9 --- /dev/null +++ b/src/Take.Blip.Builder.Benchmark/Program.cs @@ -0,0 +1,4 @@ +using BenchmarkDotNet.Running; +using Take.Blip.Builder.Benchmark.Actions; + +BenchmarkRunner.Run(); \ No newline at end of file diff --git a/src/Take.Blip.Builder.Benchmark/Take.Blip.Builder.Benchmark.csproj b/src/Take.Blip.Builder.Benchmark/Take.Blip.Builder.Benchmark.csproj new file mode 100644 index 00000000..1e958f20 --- /dev/null +++ b/src/Take.Blip.Builder.Benchmark/Take.Blip.Builder.Benchmark.csproj @@ -0,0 +1,18 @@ + + + + net8.0 + true + True + latest + Exe + + + + + + + + + + diff --git a/src/Take.Blip.Builder.UnitTests/Actions/ExecuteScript2ActionTests.cs b/src/Take.Blip.Builder.UnitTests/Actions/ExecuteScript2ActionTests.cs new file mode 100644 index 00000000..6e922a24 --- /dev/null +++ b/src/Take.Blip.Builder.UnitTests/Actions/ExecuteScript2ActionTests.cs @@ -0,0 +1,1002 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.ClearScript; +using Newtonsoft.Json.Linq; +using NSubstitute; +using Serilog; +using Shouldly; +using Take.Blip.Builder.Actions.ExecuteScriptV2; +using Take.Blip.Builder.Hosting; +using Take.Blip.Builder.Utils; +using Xunit; + +namespace Take.Blip.Builder.UnitTests.Actions +{ + public class ExecuteScript2ActionTests : ActionTestsBase + { + private static ExecuteScriptV2Action GetTarget(IHttpClient client = null) + { + var configuration = new TestConfiguration(); + var conventions = new ConventionsConfiguration(); + + configuration.ExecuteScriptV2Timeout = TimeSpan.FromMilliseconds(300); + configuration.ExecuteScriptV2MaxRuntimeHeapSize = + conventions.ExecuteScriptV2MaxRuntimeHeapSize; + configuration.ExecuteScriptV2MaxRuntimeStackUsage = + conventions.ExecuteScriptV2MaxRuntimeStackUsage; + + return new ExecuteScriptV2Action(configuration, client ?? Substitute.For(), + Substitute.For()); + } + + [Fact] + public async Task ExecuteWithSingleStatementScriptShouldSucceed() + { + // Arrange + const string variableName = "variable1"; + const string variableValue = "my variable 1 value"; + var settings = new ExecuteScriptV2Settings + { + Source = $"function run() {{ return '{variableValue}'; }}", + OutputVariable = variableName + }; + var target = GetTarget(); + + // Act + await target.ExecuteAsync(Context, JObject.FromObject(settings), CancellationToken); + + // Assert + await Context.Received(1).SetVariableAsync(Arg.Any(), Arg.Any(), + CancellationToken, Arg.Any()); + await Context.Received(1).SetVariableAsync(variableName, variableValue, + CancellationToken); + } + + [Fact] + public async Task ExecuteScriptParseIntWithManyChars() + { + // Arrange + var settings = new ExecuteScriptV2Settings + { + Source = @" +function run() { + let numberTest = new Array(100000).join('Z'); + + let convert = parseInt(numberTest); + + return convert; +} +", + OutputVariable = "test" + }; + var target = GetTarget(); + + // Act + await target.ExecuteAsync(Context, JObject.FromObject(settings), CancellationToken); + + // Assert + await Context.Received(1).SetVariableAsync("test", "NaN", CancellationToken); + } + + [Fact] + public async Task ExecuteScriptWithLiteralRegularExpression() + { + // Arrange + var settings = new ExecuteScriptV2Settings + { + Source = @" +const matchEmailRegex = (input) => { + const pattern = /^[\w-.]+@([\w-]+\.)+[\w-]{2,4}$/gmi; + return input.match(pattern, 'gmi'); +} + +function run() { + return matchEmailRegex('test@blip.ai'); +} +", + OutputVariable = "test" + }; + var target = GetTarget(); + + // Act + await target.ExecuteAsync(Context, JObject.FromObject(settings), CancellationToken); + + // Assert + await Context.Received(1).SetVariableAsync("test", "[\"test@blip.ai\"]", CancellationToken); + } + + [Fact] + public async Task ExecuteWithCustomTimeZoneDateStringAndTimeStringShouldWork() + { + // Arrange + var settings = new ExecuteScriptV2Settings + { + // Fixed date to test timezone + Source = + "function run() { return time.parseDate('2021-01-01T00:00:10').toDateString() + ' ' + time.parseDate('2021-01-01T00:00:10').toTimeString(); }", + OutputVariable = "test", + LocalTimeZoneEnabled = true + }; + var target = GetTarget(); + + Context.Flow.Configuration["builder:#localTimeZone"] = "Asia/Shanghai"; + + // Act + await target.ExecuteAsync(Context, JObject.FromObject(settings), CancellationToken); + + // Assert + // Jint doesn't support toLocaleString, so it will return the default date format + await Context.Received(1).SetVariableAsync("test", "Fri Jan 01 2021 11:00:10 GMT+08:00", + CancellationToken); + } + + [Fact] + public async Task ExecuteWithCustomTimeZoneDateStringAndTimeStringWithAmericaShouldWork() + { + // Arrange + var settings = new ExecuteScriptV2Settings + { + // Fixed date to test timezone + Source = + "function run() { return time.parseDate('2021-01-01T00:00:10').toDateString() + ' ' + time.parseDate('2021-01-01T00:00:10').toTimeString(); }", + OutputVariable = "test", + LocalTimeZoneEnabled = true + }; + var target = GetTarget(); + + Context.Flow.Configuration["builder:#localTimeZone"] = "America/Sao_Paulo"; + + // Act + await target.ExecuteAsync(Context, JObject.FromObject(settings), CancellationToken); + + // Assert + // Jint doesn't support toLocaleString, so it will return the default date format + await Context.Received(1).SetVariableAsync("test", + "Fri Jan 01 2021 00:00:10 GMT-03:00", CancellationToken); + } + + [Fact] + public async Task ExecuteWithCustomTimeZoneStringMethodsShouldBeTheSame() + { + // Arrange + var settings = new ExecuteScriptV2Settings + { + // Fixed date to test timezone + Source = @" +function run() { + var parsedDate = time.parseDate('2021-01-01T00:00:10'); + + return (parsedDate.toDateString() + ' ' + parsedDate.toTimeString()) == parsedDate.toString(); +}", + OutputVariable = "test", + LocalTimeZoneEnabled = true + }; + var target = GetTarget(); + + Context.Flow.Configuration["builder:#localTimeZone"] = "Asia/Shanghai"; + + // Act + await target.ExecuteAsync(Context, JObject.FromObject(settings), CancellationToken); + + // Assert + // Jint doesn't support toLocaleString, so it will return the default date format + await Context.Received(1).SetVariableAsync("test", "true", CancellationToken); + } + + + [Fact] + public async Task ExecuteThrowExceptionTest() + { + // Arrange + var settings = new ExecuteScriptV2Settings + { + Source = $"function run() {{ throw new Error('Test error'); }}", + OutputVariable = "variable1", + CaptureExceptions = true, + ExceptionVariable = "exception" + }; + var target = GetTarget(); + + // Act + await target.ExecuteAsync(Context, JObject.FromObject(settings), CancellationToken); + + // Assert + await Context.Received(1).SetVariableAsync("exception", "Error: Test error", + CancellationToken); + } + + [Fact] + public async Task ExecuteWithArgumentsShouldSucceed() + { + // Arrange + const string number1 = "100"; + const string number2 = "250"; + Context.GetVariableAsync(nameof(number1), CancellationToken).Returns(number1); + Context.GetVariableAsync(nameof(number2), CancellationToken).Returns(number2); + + var settings = new ExecuteScriptV2Settings + { + InputVariables = new[] { nameof(number1), nameof(number2) }, + Source = @" +function run(number1, number2) { + return parseInt(number1) + parseInt(number2); +}", + OutputVariable = "result" + }; + var target = GetTarget(); + + // Act + await target.ExecuteAsync(Context, JObject.FromObject(settings), CancellationToken); + + // Assert + await Context.Received(1).SetVariableAsync(Arg.Any(), Arg.Any(), + CancellationToken, Arg.Any()); + await Context.Received(1).SetVariableAsync("result", "350", CancellationToken); + } + + [Fact] + public async Task ExecuteSetContextVariableShouldSucceed() + { + // Arrange + var settings = new ExecuteScriptV2Settings + { + Source = @" +async function run() { + await context.setVariableAsync('test', 100); + + return true; +}", + OutputVariable = "result" + }; + var target = GetTarget(); + + // Act + await target.ExecuteAsync(Context, JObject.FromObject(settings), CancellationToken); + + // Assert + await Context.Received(1).SetVariableAsync("test", "100", Arg.Any()); + await Context.Received(1) + .SetVariableAsync("result", "true", Arg.Any()); + } + + [Fact] + public async Task ExecuteMultipleAsyncResults() + { + // Arrange + var settings = new ExecuteScriptV2Settings + { + Source = @" +async function testNum() { + return 1; +} + +async function testStr() { + return 'bla'; +} + +async function testRecursiveAsync() { + return await testStr(); +} + +async function run() { + return { + 'num': testNum(), + 'str': testStr(), + 'recursive': testRecursiveAsync() + }; +}", + OutputVariable = "result" + }; + var target = GetTarget(); + + // Act + await target.ExecuteAsync(Context, JObject.FromObject(settings), CancellationToken); + + // Assert + await Context.Received(1).SetVariableAsync("result", + "{\"num\":1,\"str\":\"bla\",\"recursive\":\"bla\"}", Arg.Any()); + } + + [Fact] + public async Task ExecuteWithMissingArgumentsShouldSucceed() + { + // Arrange + const string number1 = "100"; + const string number2 = "250"; + Context.GetVariableAsync(nameof(number1), CancellationToken).Returns(number1); + Context.GetVariableAsync(nameof(number2), CancellationToken).Returns(number2); + + var settings = new ExecuteScriptV2Settings + { + InputVariables = new[] { nameof(number1), nameof(number2) }, + Source = @" +function run(number1, number2, number3) { + return parseInt(number1) + parseInt(number2) + (number3 || 150); +}", + OutputVariable = "result" + }; + var target = GetTarget(); + + // Act + await target.ExecuteAsync(Context, JObject.FromObject(settings), CancellationToken); + + // Assert + await Context.Received(1).SetVariableAsync(Arg.Any(), Arg.Any(), + CancellationToken, Arg.Any()); + await Context.Received(1).SetVariableAsync("result", "500", CancellationToken); + } + + [Fact] + public async Task ExecuteUsingLetAndConstVariablesShouldHaveScopeAndSucceed() + { + // Arrange + var settings = new ExecuteScriptV2Settings + { + InputVariables = Array.Empty(), + Source = @" +function scopedFunc() { + let x = 1; + const y = 'my value'; + return { x: x, y: y }; +} + +function run() { + var scopedReturn = scopedFunc(); + return typeof x === 'undefined' && typeof y === 'undefined' && scopedReturn.x === 1 && scopedReturn.y === 'my value'; +}", + OutputVariable = "result" + }; + var target = GetTarget(); + + // Act + await target.ExecuteAsync(Context, JObject.FromObject(settings), CancellationToken); + + // Assert + await Context.Received(1).SetVariableAsync("result", "true", CancellationToken); + } + + [Fact] + public async Task ExecuteWithCustomFunctionNameAndArgumentsShouldSucceed() + { + // Arrange + const string number1 = "100"; + const string number2 = "250"; + Context.GetVariableAsync(nameof(number1), CancellationToken).Returns(number1); + Context.GetVariableAsync(nameof(number2), CancellationToken).Returns(number2); + + var settings = new ExecuteScriptV2Settings + { + Function = "executeFunc", + InputVariables = new[] { nameof(number1), nameof(number2) }, + Source = @" +function executeFunc(number1, number2) { + return parseInt(number1) + parseInt(number2); +}", + OutputVariable = "result" + }; + var target = GetTarget(); + + // Act + await target.ExecuteAsync(Context, JObject.FromObject(settings), CancellationToken); + + // Assert + await Context.Received(1).SetVariableAsync(Arg.Any(), Arg.Any(), + CancellationToken, Arg.Any()); + await Context.Received(1).SetVariableAsync("result", "350", CancellationToken); + } + + [Fact] + public async Task ExecuteWithJsonReturnValueShouldSucceed() + { + // Arrange + var result = + "{\"id\":1,\"valid\":true,\"options\":[1,2,3],\"names\":[\"a\",\"b\",\"c\"],\"others\":[{\"a\":\"value1\"},{\"b\":\"value2\"}],\"content\":{\"uri\":\"https://server.com/image.jpeg\",\"type\":\"image/jpeg\"}}"; + var settings = new ExecuteScriptV2Settings + { + Source = @" +function run() { + return { + id: 1, + valid: true, + options: [ 1, 2, 3 ], + names: [ 'a', 'b', 'c' ], + others: [{ a: 'value1' }, { b: 'value2' }], + content: { + uri: 'https://server.com/image.jpeg', + type: 'image/jpeg' + } + }; +} +", + OutputVariable = "result" + }; + var target = GetTarget(); + + // Act + await target.ExecuteAsync(Context, JObject.FromObject(settings), CancellationToken); + + // Assert + await Context.Received(1).SetVariableAsync(Arg.Any(), Arg.Any(), + CancellationToken, Arg.Any()); + await Context.Received(1).SetVariableAsync("result", result, CancellationToken); + } + + [Fact] + public async Task ExecuteWithArrayReturnValueShouldSucceed() + { + // Arrange + const string result = "[1,2,3]"; + var settings = new ExecuteScriptV2Settings + { + Source = @" +function run() { + return [1, 2, 3]; +} +", + OutputVariable = "result" + }; + var target = GetTarget(); + + // Act + await target.ExecuteAsync(Context, JObject.FromObject(settings), CancellationToken); + + // Assert + await Context.Received(1).SetVariableAsync(Arg.Any(), Arg.Any(), + CancellationToken, Arg.Any()); + await Context.Received(1).SetVariableAsync("result", result, CancellationToken); + } + + [Fact] + public async Task ExecuteWithWhileTrueShouldFail() + { + // Arrange + var settings = new ExecuteScriptV2Settings + { + Source = @" +function run() { + var value = 0; + while (true) { + value++; + } + return value; +}", + OutputVariable = "result" + }; + var target = GetTarget(); + + // Act + try + { + await target.ExecuteAsync(Context, JObject.FromObject(settings), CancellationToken); + throw new Exception("The script was executed"); + } + catch (TimeoutException ex) + { + ex.Message.ShouldBe("Script execution timed out"); + } + } + + + [Fact] + public async Task ExecuteWithDefaultTimeZoneShouldWork() + { + // Arrange + var settings = new ExecuteScriptV2Settings + { + // Test date parsing and also converting to specific format and timezone + Source = + "function run() { return time.parseDate('2021-01-01T00:00:00Z', {format:'yyyy-MM-ddTHH:mm:ssZ'}).toLocaleString('pt-BR', { timeZone: 'America/Sao_Paulo' }); }", + OutputVariable = "test" + }; + var target = GetTarget(); + + // Act + await target.ExecuteAsync(Context, JObject.FromObject(settings), CancellationToken); + + // Assert + await Context.Received(1) + .SetVariableAsync("test", "31/12/2020, 21:00:00", CancellationToken); + } + + [Fact] + public async Task ExecuteParseDateOverloads() + { + // Arrange + var settings = new ExecuteScriptV2Settings + { + // Test date parsing and also converting to specific format and timezone + Source = + @" +function run() { + return { + 'parseDate': time.parseDate('2021-01-01T19:01:01.0000001+08:00'), + 'parseDateWithFormat': time.parseDate('01/02/2021', {format:'MM/dd/yyyy'}), + 'parseDateWithFormatAndCulture': time.parseDate('01/01/2021', {format: 'MM/dd/yyyy', culture: 'pt-BR'}), + } +}", + OutputVariable = "test" + }; + var target = GetTarget(); + + // Act + await target.ExecuteAsync(Context, JObject.FromObject(settings), CancellationToken); + + // Assert + await Context.Received(1) + .SetVariableAsync("test", + "{\"parseDate\":\"2021-01-01T08:01:01.0000000-03:00\",\"parseDateWithFormat\":\"2021-01-02T00:00:00.0000000-03:00\",\"parseDateWithFormatAndCulture\":\"2021-01-01T00:00:00.0000000-03:00\"}", + CancellationToken); + } + + [Fact] + public async Task ExecuteWithCustomTimeZoneShouldWork() + { + // Arrange + var settings = new ExecuteScriptV2Settings + { + // Test date parsing from GMT, with bot on Asia/Shanghai (+8 from GMT) and then converting to SP (-3 from GMT) + Source = + "function run() { return time.parseDate('2021-01-01T00:00:00Z', {format:'yyyy-MM-ddTHH:mm:ssZ'}).toLocaleString('en-US', { timeZone: 'America/Sao_Paulo' }); }", + OutputVariable = "test", + LocalTimeZoneEnabled = true + }; + var target = GetTarget(); + + Context.Flow.Configuration["builder:#localTimeZone"] = "Asia/Shanghai"; + + // Act + await target.ExecuteAsync(Context, JObject.FromObject(settings), CancellationToken); + + // Assert + await Context.Received(1).SetVariableAsync("test", "12/31/2020, 9:00:00 PM", + CancellationToken); + } + + [Fact] + public async Task ExecuteWithArrowFunctionOnRun() + { + // Arrange + var settings = new ExecuteScriptV2Settings + { + Source = + @" +anArrowFunction = () => { + return 'foo'; +} + +async function run() { + return anArrowFunction(); +} +", + OutputVariable = "test", + LocalTimeZoneEnabled = true + }; + var target = GetTarget(); + + // Act + await target.ExecuteAsync(Context, JObject.FromObject(settings), CancellationToken); + + // Assert + await Context.Received(1).SetVariableAsync("test", "foo", CancellationToken); + } + + [Fact] + public async Task ExecuteWithArrowFunctionEntrypointShouldWork() + { + // Arrange + var settings = new ExecuteScriptV2Settings + { + Source = + @" +run = async () => { + return 'foo'; +} +", + OutputVariable = "test", + LocalTimeZoneEnabled = true + }; + var target = GetTarget(); + + // Act + await target.ExecuteAsync(Context, JObject.FromObject(settings), CancellationToken); + + // Assert + await Context.Received(1).SetVariableAsync("test", "foo", CancellationToken); + } + + [Fact] + public async Task ExecuteDateToStringWithCustomTimeZoneShouldWork() + { + // Arrange + var settings = new ExecuteScriptV2Settings + { + // Test date parsing and also converting to specific format and timezone + Source = + "function run() { return time.dateToString(time.parseDate('2021-01-01T00:00:00Z', {'format':'yyyy-MM-ddTHH:mm:ssZ'})); }", + OutputVariable = "test", + LocalTimeZoneEnabled = true + }; + var target = GetTarget(); + + Context.Flow.Configuration["builder:#localTimeZone"] = "Asia/Shanghai"; + + // Act + await target.ExecuteAsync(Context, JObject.FromObject(settings), CancellationToken); + + // Assert + await Context.Received(1).SetVariableAsync("test", "2021-01-01T08:00:00.0000000+08:00", + CancellationToken); + } + + [Fact] + public async Task ExecuteParseDateWithDefaultFormat() + { + // Arrange + var settings = new ExecuteScriptV2Settings + { + // Test date parsing and also converting to specific format and timezone + Source = + @" +function run() { + var parsed = time.parseDate('2021-01-01T19:00:00.0000000'); + + var stringDate = time.dateToString(parsed); + + return { + 'parsed': parsed, + 'stringDate': stringDate + } +}", + OutputVariable = "test", + LocalTimeZoneEnabled = true + }; + var target = GetTarget(); + + Context.Flow.Configuration["builder:#localTimeZone"] = "America/Sao_Paulo"; + + // Act + await target.ExecuteAsync(Context, JObject.FromObject(settings), CancellationToken); + + // Assert + await Context.Received(1).SetVariableAsync("test", + "{\"parsed\":\"2021-01-01T19:00:00.0000000-03:00\",\"stringDate\":\"2021-01-01T19:00:00.0000000-03:00\"}", + CancellationToken); + } + + [Fact] + public async Task ExecuteWithInfiniteSleepShouldFail() + { + // Arrange + var settings = new ExecuteScriptV2Settings + { + Source = @" +function run() { + time.sleep(1000000000); + + return value; +}", + OutputVariable = "result" + }; + var target = GetTarget(); + + // Act + try + { + await target.ExecuteAsync(Context, JObject.FromObject(settings), CancellationToken); + throw new Exception("The script was executed"); + } + catch (TimeoutException ex) + { + ex.Message.ShouldBe("Script execution timed out"); + } + catch (ScriptEngineException ex) + { + ex.Message.ShouldBe("Error: Script execution timed out"); + } + } + + [Fact] + public async Task ExecuteScripWithXmlHttpRequestShouldFail() + { + // Arrange + var settings = new ExecuteScriptV2Settings + { + Source = @" +function run() { + var xhr = new XMLHttpRequest(); + xhr.onreadystatechange = function() { + if (xhr.readyState == XMLHttpRequest.DONE) { + alert(xhr.responseText); + } + } + xhr.open('GET', 'https://example.com', true); + xhr.send(null); +}", + OutputVariable = "result" + }; + var target = GetTarget(); + + // Act + try + { + await target.ExecuteAsync(Context, JObject.FromObject(settings), CancellationToken); + throw new Exception("The script was executed"); + } + catch (ScriptEngineException ex) + { + ex.Message.ShouldContain("XMLHttpRequest is not defined"); + } + } + + [Fact] + [SuppressMessage("Performance", "CA1861:Avoid constant arrays as arguments")] + public async Task ExecuteScriptWithFetchRequestShouldSucceed() + { + // Arrange + var settings = new ExecuteScriptV2Settings + { + Source = @" +async function run() { + var response = await request.fetchAsync('https://mock.com', { + 'method': 'POST', + 'body': 'r8eht438thj9848', + 'headers': { + 'Content-Type': 'application/text', + 'test': 'test2', + 'test2': ['bla', 'bla2'] + } + }); + + return response; +}", + OutputVariable = "result" + }; + + using var response = new HttpResponseMessage(); + response.StatusCode = HttpStatusCode.OK; + response.Content = new StringContent("{\"result\": \"bla\"}"); + response.Headers.Add("test", "test2"); + response.Headers.Add("test2", new[] { "bla", "bla2" }); + + var httpClient = Substitute.For(); + + HttpRequestMessage resultMessage = null; + httpClient.SendAsync(Arg.Do(message => + { + resultMessage = new HttpRequestMessage + { + Method = message.Method, + RequestUri = message.RequestUri, + Content = new StringContent(message.Content!.ReadAsStringAsync() + .GetAwaiter() + .GetResult(), Encoding.UTF8, + message.Content.Headers.ContentType?.MediaType!) + }; + + for (var i = 0; i < message.Headers.Count(); i++) + { + var header = message.Headers.ElementAt(i); + resultMessage.Headers.TryAddWithoutValidation(header.Key, header.Value); + } + }), Arg.Any()) + .Returns(response); + + var target = GetTarget(httpClient); + + // Act + await target.ExecuteAsync(Context, JObject.FromObject(settings), CancellationToken); + + // Assert + await Context.Received(1).SetVariableAsync("result", + "{\"status\":200,\"success\":true,\"body\":\"{\\\"result\\\": \\\"bla\\\"}\",\"headers\":{\"test\":[\"test2\"],\"test2\":[\"bla\",\"bla2\"]}}", + CancellationToken); + + resultMessage.Method.ShouldBe(HttpMethod.Post); + resultMessage.RequestUri.ShouldBe(new Uri("https://mock.com")); + + var requestBody = await resultMessage.Content!.ReadAsStringAsync(); + + requestBody.ShouldBe("r8eht438thj9848"); + + resultMessage.Headers.GetValues("test").First().ShouldBe("test2"); + resultMessage.Headers.GetValues("test2").ShouldBe(new[] { "bla", "bla2" }); + + resultMessage.Content.Headers.ContentType!.MediaType.ShouldBe("application/text"); + } + + [Fact] + [SuppressMessage("Performance", "CA1861:Avoid constant arrays as arguments")] + public async Task ExecuteScriptWithRequestParseJsonResponse() + { + // Arrange + var settings = new ExecuteScriptV2Settings + { + Source = @" +async function run() { + var response = await request.fetchAsync('https://mock.com', { + 'method': 'POST', + 'body': 'r8eht438thj9848', + 'headers': { + 'Content-Type': 'application/text', + 'test': 'test2', + 'test2': ['bla', 'bla2'] + } + }); + + return await response.jsonAsync(); +}", + OutputVariable = "result" + }; + + using var response = new HttpResponseMessage(); + response.StatusCode = HttpStatusCode.OK; + response.Content = + new StringContent("{\"result\": \"bla\"}", Encoding.UTF8, "application/json"); + response.Headers.Add("test", "test2"); + response.Headers.Add("test2", new[] { "bla", "bla2" }); + + var httpClient = Substitute.For(); + + httpClient.SendAsync(Arg.Any(), Arg.Any()) + .Returns(response); + + var target = GetTarget(httpClient); + + // Act + await target.ExecuteAsync(Context, JObject.FromObject(settings), CancellationToken); + + // Assert + await Context.Received(1).SetVariableAsync("result", + "{\"result\":\"bla\"}", + CancellationToken); + } + + [Fact] + [SuppressMessage("Performance", "CA1861:Avoid constant arrays as arguments")] + public async Task ExecuteScriptWithFetchRequestWithoutOptionsShouldSucceed() + { + // Arrange + var settings = new ExecuteScriptV2Settings + { + Source = @" +async function run() { + var response = await request.fetchAsync('https://mock.com'); + + return response; +}", + OutputVariable = "result" + }; + + using var response = new HttpResponseMessage(); + response.StatusCode = HttpStatusCode.OK; + response.Content = new StringContent("{\"result\": \"bla\"}"); + response.Headers.Add("test", "test2"); + response.Headers.Add("test2", new[] { "bla", "bla2" }); + + var httpClient = Substitute.For(); + + HttpRequestMessage resultMessage = null; + httpClient.SendAsync(Arg.Do(message => + { + resultMessage = new HttpRequestMessage + { + Method = message.Method, + RequestUri = message.RequestUri, + Content = message.Content, + }; + + for (var i = 0; i < message.Headers.Count(); i++) + { + var header = message.Headers.ElementAt(i); + resultMessage.Headers.TryAddWithoutValidation(header.Key, header.Value); + } + }), Arg.Any()) + .Returns(response); + + var target = GetTarget(httpClient); + + // Act + await target.ExecuteAsync(Context, JObject.FromObject(settings), CancellationToken); + + // Assert + await Context.Received(1).SetVariableAsync("result", + "{\"status\":200,\"success\":true,\"body\":\"{\\\"result\\\": \\\"bla\\\"}\",\"headers\":{\"test\":[\"test2\"],\"test2\":[\"bla\",\"bla2\"]}}", + CancellationToken); + + resultMessage.Method.ShouldBe(HttpMethod.Get); + resultMessage.RequestUri.ShouldBe(new Uri("https://mock.com")); + resultMessage.Content.ShouldBeNull(); + } + + [Fact] + public async Task ExecuteScriptParseIntWithExceededLengthShouldSucceed() + { + // Arrange + var result = "NaN"; + var settings = new ExecuteScriptV2Settings + { + Source = @" +function run() { + let numberTest = new Array(100000).join('Z'); + let convert = parseInt(numberTest); + return convert; +}", + OutputVariable = "result" + }; + var target = GetTarget(); + + // Act + await target.ExecuteAsync(Context, JObject.FromObject(settings), CancellationToken); + + // Assert + await Context.Received(1).SetVariableAsync(Arg.Any(), Arg.Any(), + CancellationToken, Arg.Any()); + await Context.Received(1).SetVariableAsync("result", result, CancellationToken); + } + + [Fact] + public async Task ExecuteScriptJsonParseWithSpecialCharacterShouldSucceed() + { + // Arrange + var invalidCharacter = "?"; + Context.GetVariableAsync(nameof(invalidCharacter), CancellationToken) + .Returns(invalidCharacter); + var result = "{\"value\":\"\"}"; + + var settings = new ExecuteScriptV2Settings + { + InputVariables = new[] { nameof(invalidCharacter) }, + Source = @" +function run (input) { + try { + return JSON.parse(input); + } catch (e) { + return { + value: '' + }; + } +}", + OutputVariable = "result" + }; + + var target = GetTarget(); + + // Act + await target.ExecuteAsync(Context, JObject.FromObject(settings), CancellationToken); + + // Assert + await Context.Received(1).SetVariableAsync(Arg.Any(), Arg.Any(), + CancellationToken, Arg.Any()); + await Context.Received(1).SetVariableAsync("result", result, CancellationToken); + } + + [SuppressMessage("ReSharper", "UnusedAutoPropertyAccessor.Local")] + private class TestConfiguration : IConfiguration + { + public TimeSpan InputProcessingTimeout { get; set; } + public int RedisDatabase { get; set; } + public string RedisKeyPrefix { get; set; } + public string InternalUris { get; set; } + public int MaxTransitionsByInput { get; set; } + public int TraceQueueBoundedCapacity { get; set; } + public int TraceQueueMaxDegreeOfParallelism { get; set; } + public TimeSpan TraceTimeout { get; set; } + public TimeSpan DefaultActionExecutionTimeout { get; set; } + public int ExecuteScriptLimitRecursion { get; set; } + public int ExecuteScriptMaxStatements { get; set; } + public long ExecuteScriptLimitMemory { get; set; } + public long ExecuteScriptLimitMemoryWarning { get; set; } + public TimeSpan ExecuteScriptTimeout { get; set; } + public TimeSpan ExecuteScriptV2Timeout { get; set; } + public int MaximumInputExpirationLoop { get; set; } + public long ExecuteScriptV2MaxRuntimeHeapSize { get; set; } + public long ExecuteScriptV2MaxRuntimeStackUsage { get; set; } + } + } +} \ No newline at end of file diff --git a/src/Take.Blip.Builder.UnitTests/Actions/ExecuteScriptActionTests.cs b/src/Take.Blip.Builder.UnitTests/Actions/ExecuteScriptActionTests.cs index 69b6d62a..4d928143 100644 --- a/src/Take.Blip.Builder.UnitTests/Actions/ExecuteScriptActionTests.cs +++ b/src/Take.Blip.Builder.UnitTests/Actions/ExecuteScriptActionTests.cs @@ -1,7 +1,4 @@ using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; using System.Threading.Tasks; using Jint.Runtime; using Newtonsoft.Json.Linq; @@ -42,6 +39,49 @@ public async Task ExecuteWithSingleStatementScriptShouldSucceed() await Context.Received(1).SetVariableAsync(variableName, variableValue, CancellationToken, default(TimeSpan)); } + [Fact] + public async Task ExecuteWithDefaultTimeZoneShouldWork() + { + // Arrange + var settings = new ExecuteScriptSettings + { + // Fixed date to test timezone + Source = "function run() { return new Date('2021-01-01T00:00:10').toLocaleString('en-US'); }", + OutputVariable = "test" + }; + var target = GetTarget(); + + // Act + await target.ExecuteAsync(Context, JObject.FromObject(settings), CancellationToken); + + // Assert + // Jint doesn't support toLocaleString, so it will return the default date format + await Context.Received(1).SetVariableAsync("test", "Thursday, 31 December 2020 21:00:10", CancellationToken); + } + + [Fact] + public async Task ExecuteWithCustomTimeZoneShouldWork() + { + // Arrange + var settings = new ExecuteScriptSettings + { + // Fixed date to test timezone + Source = "function run() { return new Date('2021-01-01T00:00:10').toLocaleString('en-US'); }", + OutputVariable = "test", + LocalTimeZoneEnabled = true + }; + var target = GetTarget(); + + Context.Flow.Configuration["builder:#localTimeZone"] = "Asia/Shanghai"; + + // Act + await target.ExecuteAsync(Context, JObject.FromObject(settings), CancellationToken); + + // Assert + // Jint doesn't support toLocaleString, so it will return the default date format + await Context.Received(1).SetVariableAsync("test", "Friday, 1 January 2021 08:00:10", CancellationToken); + } + [Fact] public async Task ExecuteWithArgumentsShouldSucceed() { @@ -237,7 +277,7 @@ function run() { [Fact] public async Task ExecuteWithWhileTrueShouldFail() { - // Arrange + // Arrange var result = ""; var settings = new ExecuteScriptSettings() { @@ -254,7 +294,7 @@ function run() { }; var target = GetTarget(); - // Act + // Act try { await target.ExecuteAsync(Context, JObject.FromObject(settings), CancellationToken); diff --git a/src/Take.Blip.Builder.UnitTests/Take.Blip.Builder.UnitTests.csproj b/src/Take.Blip.Builder.UnitTests/Take.Blip.Builder.UnitTests.csproj index 3b48d43d..e8792f61 100644 --- a/src/Take.Blip.Builder.UnitTests/Take.Blip.Builder.UnitTests.csproj +++ b/src/Take.Blip.Builder.UnitTests/Take.Blip.Builder.UnitTests.csproj @@ -2,6 +2,7 @@ netcoreapp3.1;net6.0;net8.0 8.0 + true diff --git a/src/Take.Blip.Builder/Actions/ExecuteScriptV2/ExecuteScriptV2Action.cs b/src/Take.Blip.Builder/Actions/ExecuteScriptV2/ExecuteScriptV2Action.cs new file mode 100644 index 00000000..59b4acc8 --- /dev/null +++ b/src/Take.Blip.Builder/Actions/ExecuteScriptV2/ExecuteScriptV2Action.cs @@ -0,0 +1,178 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; +using Lime.Protocol; +using Microsoft.ClearScript; +using Microsoft.ClearScript.V8; +using Serilog; +using Take.Blip.Builder.Actions.ExecuteScriptV2.Functions; +using Take.Blip.Builder.Hosting; +using Take.Blip.Builder.Utils; + +namespace Take.Blip.Builder.Actions.ExecuteScriptV2 +{ + /// + public class ExecuteScriptV2Action : ActionBase + { + private readonly IConfiguration _configuration; + private readonly IHttpClient _httpClient; + private readonly ILogger _logger; + + /// + public ExecuteScriptV2Action( + IConfiguration configuration, + IHttpClient httpClient, + ILogger logger) + : base(nameof(ExecuteScriptV2)) + { + HostSettings.CustomAttributeLoader = new LowerCaseMembersLoader(); + + _configuration = configuration; + _httpClient = httpClient; + _logger = logger; + } + + /// + public override async Task ExecuteAsync(IContext context, ExecuteScriptV2Settings settings, + CancellationToken cancellationToken) + { + try + { + var arguments = await GetScriptArgumentsAsync(context, settings, cancellationToken); + + using var engine = new V8ScriptEngine( + V8ScriptEngineFlags.AddPerformanceObject | + V8ScriptEngineFlags.EnableTaskPromiseConversion | + V8ScriptEngineFlags.UseSynchronizationContexts | + V8ScriptEngineFlags.EnableStringifyEnhancements | + V8ScriptEngineFlags.EnableDateTimeConversion | + V8ScriptEngineFlags.EnableValueTaskPromiseConversion | + V8ScriptEngineFlags.HideHostExceptions + ); + + engine.DocumentSettings.AccessFlags |= DocumentAccessFlags.AllowCategoryMismatch; + engine.MaxRuntimeHeapSize = + new UIntPtr((ulong)_configuration.ExecuteScriptV2MaxRuntimeHeapSize); + engine.MaxRuntimeStackUsage = + new UIntPtr((ulong)_configuration.ExecuteScriptV2MaxRuntimeStackUsage); + engine.AllowReflection = false; + + engine.RuntimeHeapSizeViolationPolicy = V8RuntimeViolationPolicy.Exception; + + // Create new token cancellation token with _configuration.ExecuteScriptV2Timeout based on the current token + using var timeoutToken = + new CancellationTokenSource(_configuration.ExecuteScriptV2Timeout); + using var linkedToken = + CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, + timeoutToken.Token); + + var time = new Time(_logger, context, settings, linkedToken.Token); + + engine.RegisterFunctions(settings, _httpClient, context, time, _logger, + linkedToken.Token); + + var result = engine.ExecuteInvoke(settings.Source, settings.Function, + _configuration.ExecuteScriptV2Timeout, arguments); + + await SetScriptResultAsync(context, settings, result, time, cancellationToken); + } + catch (Exception ex) + { + if (!settings.CaptureExceptions) + { + throw; + } + + string exceptionMessage = null; + + try + { + exceptionMessage = + await _captureException(context, settings, cancellationToken, ex); + } + finally + { + var trace = context.GetCurrentActionTrace(); + if (trace != null) + { + trace.Warning = exceptionMessage ?? + "An error occurred while executing the script."; + } + } + } + } + + private async Task _captureException(IContext context, + ExecuteScriptV2Settings settings, + CancellationToken cancellationToken, Exception ex) + { + string exceptionMessage; + + if (ex is ScriptEngineException || + ex is ScriptInterruptedException || + ex is TimeoutException || + ex is ArgumentException || + ex is ValidationException || + ex is OperationCanceledException) + { + exceptionMessage = ex.Message; + } + else + { + var traceId = Activity.Current?.Id ?? Guid.NewGuid().ToString(); + + exceptionMessage = + $"Internal script error, please contact the support with the following id: {traceId}"; + + _logger.Error(ex, "Internal unknown bot error, support trace id: {TraceId}", + traceId); + } + + if (!settings.ExceptionVariable.IsNullOrEmpty()) + { + await context.SetVariableAsync(settings.ExceptionVariable, + exceptionMessage, + cancellationToken); + } + + return exceptionMessage; + } + + private static async Task GetScriptArgumentsAsync( + IContext context, ExecuteScriptV2Settings settings, CancellationToken cancellationToken) + { + if (settings.InputVariables == null || settings.InputVariables.Length <= 0) + { + return null; + } + + object[] arguments = new object[settings.InputVariables.Length]; + for (int i = 0; i < arguments.Length; i++) + { + arguments[i] = + await context.GetVariableAsync(settings.InputVariables[i], + cancellationToken); + } + + return arguments; + } + + private static async Task SetScriptResultAsync( + IContext context, ExecuteScriptV2Settings settings, object result, Time time, + CancellationToken cancellationToken) + { + var data = await ScriptObjectConverter.ToStringAsync(result, time, cancellationToken); + + if (data != null) + { + await context.SetVariableAsync(settings.OutputVariable, data, cancellationToken); + } + else + { + await context.DeleteVariableAsync(settings.OutputVariable, cancellationToken); + } + } + } +} \ No newline at end of file diff --git a/src/Take.Blip.Builder/Actions/ExecuteScriptV2/ExecuteScriptV2Settings.cs b/src/Take.Blip.Builder/Actions/ExecuteScriptV2/ExecuteScriptV2Settings.cs new file mode 100644 index 00000000..bd9de922 --- /dev/null +++ b/src/Take.Blip.Builder/Actions/ExecuteScriptV2/ExecuteScriptV2Settings.cs @@ -0,0 +1,69 @@ +using System.ComponentModel.DataAnnotations; +using Take.Blip.Builder.Actions.ExecuteScript; +using Take.Blip.Builder.Models; + +namespace Take.Blip.Builder.Actions.ExecuteScriptV2 +{ + /// + /// Settings for the ExecuteScriptV2 action. + /// + public class ExecuteScriptV2Settings : IValidable + { + /// + /// The function to call in the script. + /// + public string Function { get; set; } + + /// + /// The script source. + /// + public string Source { get; set; } + + /// + /// The input variables to pass to the script. + /// + public string[] InputVariables { get; set; } + + /// + /// The output variable to store the result of the script. + /// + public string OutputVariable { get; set; } + + /// + /// If the script should capture all exceptions instead of throwing them. + /// + public bool CaptureExceptions { get; set; } + + /// + /// The variable to store the exception message if CaptureExceptions is true. + /// + public string ExceptionVariable { get; set; } + + /// + /// + /// + public bool LocalTimeZoneEnabled { get; set; } + + /// + /// The current state id to send as header of the request. + /// + // ReSharper disable once InconsistentNaming + public string currentStateId { get; set; } + + /// + public void Validate() + { + if (string.IsNullOrEmpty(Source)) + { + throw new ValidationException( + $"The '{nameof(Source)}' settings value is required for '{nameof(ExecuteScriptSettings)}' action"); + } + + if (string.IsNullOrEmpty(OutputVariable)) + { + throw new ValidationException( + $"The '{nameof(OutputVariable)}' settings value is required for '{nameof(ExecuteScriptSettings)}' action"); + } + } + } +} \ No newline at end of file diff --git a/src/Take.Blip.Builder/Actions/ExecuteScriptV2/Functions/BotTimeZone.cs b/src/Take.Blip.Builder/Actions/ExecuteScriptV2/Functions/BotTimeZone.cs new file mode 100644 index 00000000..122561c4 --- /dev/null +++ b/src/Take.Blip.Builder/Actions/ExecuteScriptV2/Functions/BotTimeZone.cs @@ -0,0 +1,56 @@ +using System; +using System.Diagnostics; +using Serilog; +using TimeZoneConverter; + +namespace Take.Blip.Builder.Actions.ExecuteScriptV2.Functions +{ + /// + /// Bot timezone helper. + /// + public static class BotTimeZone + { + private const string BRAZIL_TIMEZONE = "America/Sao_Paulo"; + private const string LOCAL_TIMEZONE_SEPARATOR = "builder:#localTimeZone"; + + private static readonly TimeZoneInfo _defaultTimezone = + TZConvert.GetTimeZoneInfo(BRAZIL_TIMEZONE); + + /// + /// Get the bot timezone or Brazil timezone if not set. + /// + /// + /// + /// + /// + public static TimeZoneInfo GetTimeZone(ILogger logger, IContext context, + ExecuteScriptV2Settings settings) + { + try + { + if (context.Flow.Configuration.ContainsKey(LOCAL_TIMEZONE_SEPARATOR) && + settings.LocalTimeZoneEnabled) + { + return TZConvert.GetTimeZoneInfo( + context.Flow.Configuration[LOCAL_TIMEZONE_SEPARATOR]); + } + } + catch (Exception ex) + { + // TODO: use open telemetry to store exception after updating project to .NET 8 and adding OpenTelemetry dependency + Activity.Current?.AddEvent(new ActivityEvent(ex.Message)); + + var trace = context.GetCurrentActionTrace(); + if (trace != null) + { + trace.Warning = + $"Could not convert timezone, using default: {_defaultTimezone.Id}"; + } + + logger.Information(ex, "Error converting timezone, using default"); + } + + return _defaultTimezone; + } + } +} \ No newline at end of file diff --git a/src/Take.Blip.Builder/Actions/ExecuteScriptV2/Functions/Context.cs b/src/Take.Blip.Builder/Actions/ExecuteScriptV2/Functions/Context.cs new file mode 100644 index 00000000..e6356227 --- /dev/null +++ b/src/Take.Blip.Builder/Actions/ExecuteScriptV2/Functions/Context.cs @@ -0,0 +1,90 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Serilog; + +namespace Take.Blip.Builder.Actions.ExecuteScriptV2.Functions +{ + /// + /// Add context functions to the script engine, allowing users to change bot context inside the javascript. + /// TODO: for the future, allow adding, getting and removing variables in batch. To do it, we must have a new command for batch operations to avoid sending multiple commands. + /// + [SuppressMessage("ReSharper", "UnusedMember.Global")] + public class Context + { + private readonly IContext _context; + private readonly Time _time; + private readonly ILogger _logger; + private readonly CancellationToken _cancellationToken; + + /// + /// Initializes a new instance of the class. + /// + /// + /// + /// + /// + public Context(IContext context, Time time, ILogger logger, + CancellationToken cancellationToken) + { + _context = context; + _time = time; + _logger = logger.ForContext("OwnerIdentity", context.OwnerIdentity) + .ForContext("UserIdentity", context.UserIdentity); + _cancellationToken = cancellationToken; + } + + /// + /// Sets a variable in the context. + /// + /// + /// + /// + /// + public async Task SetVariableAsync(string key, object value, TimeSpan expiration = default) + { + var result = + await ScriptObjectConverter.ToStringAsync(value, _time, _cancellationToken); + + if (result != null) + { + _logger.Information("[{Source}] Setting variable '{VariableKey}' in the context", + "ExecuteScriptV2.Context", key); + + await _context.SetVariableAsync(key, result, _cancellationToken, + expiration: expiration); + } + + _logger.Information( + "[{Source}] Deleting variable '{VariableKey}' in the context, set value is empty", + "ExecuteScriptV2.Context", key); + + await _context.DeleteVariableAsync(key, _cancellationToken); + } + + /// + /// Deletes a variable from the context. + /// + /// + /// + public Task DeleteVariableAsync(string key) + { + _logger.Information( + "[{Source}] Deleting variable '{VariableKey}' in the context", + "ExecuteScriptV2.Context", key); + + return _context.DeleteVariableAsync(key, _cancellationToken); + } + + /// + /// Gets a variable from the context. + /// + /// + /// + public Task GetVariableAsync(string key) + { + return _context.GetVariableAsync(key, _cancellationToken); + } + } +} \ No newline at end of file diff --git a/src/Take.Blip.Builder/Actions/ExecuteScriptV2/Functions/ContextExtensions.cs b/src/Take.Blip.Builder/Actions/ExecuteScriptV2/Functions/ContextExtensions.cs new file mode 100644 index 00000000..9a68c305 --- /dev/null +++ b/src/Take.Blip.Builder/Actions/ExecuteScriptV2/Functions/ContextExtensions.cs @@ -0,0 +1,22 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Threading.Tasks; +using Microsoft.ClearScript; + +namespace Take.Blip.Builder.Actions.ExecuteScriptV2.Functions +{ + /// + /// Workaround for overload methods: https://github.com/microsoft/ClearScript/issues/432#issuecomment-1289007466 + /// + [SuppressMessage("ReSharper", "UnusedMember.Global")] + [ExcludeFromCodeCoverage] + public static class ContextExtensions + { + /// + public static Task SetVariableAsync(this Context context, string key, object value, + Undefined _) + { + return context.SetVariableAsync(key, value); + } + } +} \ No newline at end of file diff --git a/src/Take.Blip.Builder/Actions/ExecuteScriptV2/Functions/FunctionsRegistrable.cs b/src/Take.Blip.Builder/Actions/ExecuteScriptV2/Functions/FunctionsRegistrable.cs new file mode 100644 index 00000000..e93bf4b0 --- /dev/null +++ b/src/Take.Blip.Builder/Actions/ExecuteScriptV2/Functions/FunctionsRegistrable.cs @@ -0,0 +1,66 @@ +using System; +using System.Threading; +using Microsoft.ClearScript; +using Serilog; +using Take.Blip.Builder.Utils; + +namespace Take.Blip.Builder.Actions.ExecuteScriptV2.Functions +{ + /// + /// FunctionsRegistrable that can be executed by the script engine. + /// + public static class FunctionsRegistrable + { + /// + /// Registers the functions that can be executed by the script engine. + /// + /// + /// + /// + /// + /// + /// + /// + public static void RegisterFunctions(this ScriptEngine engine, + ExecuteScriptV2Settings settings, + IHttpClient httpClient, IContext context, + Time time, + ILogger logger, + CancellationToken cancellationToken) + { + // Date and time manipulation + engine.AddHostObject("time", time); + engine.AddHostType(typeof(TimeExtensions)); + engine.AddHostType(typeof(TimeSpan)); + + _setDateTimezone(engine); + + // Context access + engine.AddHostObject("context", new Context(context, time, logger, cancellationToken)); + engine.AddHostType(typeof(ContextExtensions)); + + // Fetch API + engine.AddHostObject("request", + new Request(settings, httpClient, context, time, logger, cancellationToken)); + engine.AddHostType(typeof(RequestExtensions)); + engine.AddHostType(typeof(Request.HttpResponse)); + } + + private static void _setDateTimezone(IScriptEngine engine) + { + engine.Execute(@" +Date.prototype.toDateString = function () { + return time.dateToString(this, {format: 'ddd MMM dd yyyy'}); +}; + +Date.prototype.toTimeString = function () { + return time.dateToString(this, {format: 'HH:mm:ss \'GMT\'zzz'}); +}; + +Date.prototype.toString = function () { + return this.toDateString() + ' ' + this.toTimeString(); +}; +"); + } + } +} \ No newline at end of file diff --git a/src/Take.Blip.Builder/Actions/ExecuteScriptV2/Functions/Request.cs b/src/Take.Blip.Builder/Actions/ExecuteScriptV2/Functions/Request.cs new file mode 100644 index 00000000..5990602a --- /dev/null +++ b/src/Take.Blip.Builder/Actions/ExecuteScriptV2/Functions/Request.cs @@ -0,0 +1,243 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Lime.Protocol; +using Microsoft.ClearScript; +using Newtonsoft.Json; +using Serilog; +using Take.Blip.Builder.Utils; + +namespace Take.Blip.Builder.Actions.ExecuteScriptV2.Functions +{ + /// + /// Add HTTP request functions to the script engine. + /// + [SuppressMessage("ReSharper", "UnusedMember.Global")] + public class Request + { + private readonly CancellationToken _cancellationToken; + private readonly ExecuteScriptV2Settings _settings; + private readonly IHttpClient _httpClient; + private readonly IContext _context; + private readonly Time _time; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// + /// + /// + /// + /// + /// + public Request(ExecuteScriptV2Settings settings, IHttpClient httpClient, IContext context, + Time time, + ILogger logger, + CancellationToken cancellationToken) + { + _cancellationToken = cancellationToken; + _httpClient = httpClient; + _settings = settings; + _context = context; + _time = time; + _logger = logger.ForContext("OwnerIdentity", context.OwnerIdentity) + .ForContext("UserIdentity", context.UserIdentity); + } + + /// + /// Sets a variable in the context. + /// + /// + public async Task FetchAsync(string uri, IScriptObject options = default) + { + using var httpRequestMessage = + new HttpRequestMessage( + new HttpMethod(options?.GetProperty("method").ToString() ?? "GET"), uri); + + var contentType = await _setHeadersAsync(options, httpRequestMessage); + + await _setBodyAsync(options, httpRequestMessage, contentType); + + if (_context.OwnerIdentity != null && !((string)_context.OwnerIdentity).IsNullOrEmpty()) + { + httpRequestMessage.Headers.Add(Constants.BLIP_BOT_HEADER, _context.OwnerIdentity); + } + + if (!_settings.currentStateId.IsNullOrEmpty()) + { + httpRequestMessage.Headers.Add(Constants.BLIP_STATEID_HEADER, + _settings.currentStateId); + } + + if (_context.UserIdentity != null && !((string)_context.UserIdentity).IsNullOrEmpty()) + { + httpRequestMessage.Headers.Add(Constants.BLIP_USER_HEADER, _context.UserIdentity); + } + + using var httpResponseMessage = + await _httpClient.SendAsync(httpRequestMessage, _cancellationToken); + + var responseStatus = (int)httpResponseMessage.StatusCode; + var isSuccessStatusCode = httpResponseMessage.IsSuccessStatusCode; + + var responseBody = ""; + + try + { + responseBody = await httpResponseMessage.Content.ReadAsStringAsync(); + } + catch (Exception ex) + { + _logger.Warning(ex, "Error reading response content"); + + var currentActionTrace = _context.GetCurrentActionTrace(); + if (currentActionTrace != null) + { + currentActionTrace.Warning = + $"Request.fetchAsync: failed to read body: {ex.Message}"; + } + } + + return new HttpResponse( + responseStatus, + isSuccessStatusCode, + responseBody, + httpResponseMessage.Headers.ToDictionary( + h => h.Key.ToLower(), + h => h.Value.ToArray())); + } + + private async Task _setBodyAsync(IScriptObject options, + HttpRequestMessage httpRequestMessage, + string contentType) + { + var body = options?.GetProperty("body"); + if (body != null) + { + var requestBody = + await ScriptObjectConverter.ToStringAsync(body, _time, _cancellationToken); + + httpRequestMessage.Content = new StringContent(requestBody, Encoding.UTF8, + contentType ?? "application/json"); + } + } + + private async Task _setHeadersAsync(IScriptObject options, + HttpRequestMessage httpRequestMessage) + { + string contentType = "application/json"; + if (!(options?.GetProperty("headers") is IScriptObject headers)) + { + return contentType; + } + + foreach (var header in headers.PropertyNames) + { + var headerValue = await ScriptObjectConverter.ConvertAsync( + headers.GetProperty(header), _time, + _cancellationToken); + + switch (headerValue) + { + case string value: + if (header.Equals("content-type", + StringComparison.CurrentCultureIgnoreCase)) + { + contentType = value; + } + + httpRequestMessage.Headers.TryAddWithoutValidation(header, value); + break; + case List values: + if (header.Equals("content-type", + StringComparison.CurrentCultureIgnoreCase) && + values.Count > 0) + { + contentType = values[0].ToString(); + } + + httpRequestMessage.Headers.TryAddWithoutValidation(header, + values.Select(v => v.ToString()).ToArray()); + break; + } + } + + return contentType; + } + + /// + /// The representation of the HTTP Response. + /// + public sealed class HttpResponse + { + /// + /// Gets the response body as a JSON object. + /// + [JsonProperty("status")] + public int Status { get; set; } + + /// + /// Gets the response body as a JSON object. + /// + [JsonProperty("success")] + public bool Success { get; set; } + + /// + /// Gets the response body as a JSON object. + /// + [JsonProperty("body")] + public string Body { get; set; } + + /// + /// Gets the response body as a JSON object. + /// + [JsonProperty("headers")] + public IDictionary Headers { get; set; } + + /// + /// The representation of the HTTP Response. + /// + /// + /// + /// + /// + public HttpResponse(int status, bool success, string body, + Dictionary headers) + { + Status = status; + Success = success; + Body = body; + Headers = headers; + } + + /// + /// Gets the response body as a JSON object. + /// + /// + public Task JsonAsync() + { + return Task.FromResult(ScriptEngine.Current.Script.JSON.parse(Body)); + } + + /// + /// Gets the response body as a JSON object. + /// + /// + /// + public string[] GetHeader(string key) + { + return Headers.TryGetValue(key.ToLower(), out var value) + ? value +#pragma warning disable S1168 - Return null to diferentiate from empty array + : null; +#pragma warning restore S1168 + } + } + } +} \ No newline at end of file diff --git a/src/Take.Blip.Builder/Actions/ExecuteScriptV2/Functions/RequestExtensions.cs b/src/Take.Blip.Builder/Actions/ExecuteScriptV2/Functions/RequestExtensions.cs new file mode 100644 index 00000000..d583ec12 --- /dev/null +++ b/src/Take.Blip.Builder/Actions/ExecuteScriptV2/Functions/RequestExtensions.cs @@ -0,0 +1,22 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Threading.Tasks; +using Microsoft.ClearScript; + +namespace Take.Blip.Builder.Actions.ExecuteScriptV2.Functions +{ + /// + /// Workaround for overload methods: https://github.com/microsoft/ClearScript/issues/432#issuecomment-1289007466 + /// + [SuppressMessage("ReSharper", "UnusedMember.Global")] + [ExcludeFromCodeCoverage] + public static class RequestExtensions + { + /// + public static Task FetchAsync(this Request request, string uri, + Undefined _) + { + return request.FetchAsync(uri); + } + } +} \ No newline at end of file diff --git a/src/Take.Blip.Builder/Actions/ExecuteScriptV2/Functions/Time.cs b/src/Take.Blip.Builder/Actions/ExecuteScriptV2/Functions/Time.cs new file mode 100644 index 00000000..074ba191 --- /dev/null +++ b/src/Take.Blip.Builder/Actions/ExecuteScriptV2/Functions/Time.cs @@ -0,0 +1,147 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Threading; +using System.Threading.Tasks; +using Lime.Protocol; +using Microsoft.ClearScript; +using Serilog; + +namespace Take.Blip.Builder.Actions.ExecuteScriptV2.Functions +{ + /// + /// Time to manipulate time inside the script engine + /// + [SuppressMessage("ReSharper", "UnusedMember.Global")] + public class Time + { + private const string DEFAULT_TIME_FORMAT = "yyyy-MM-dd'T'HH:mm:ss.fffffffK"; + private const string DEFAULT_CULTURE_INFO = "en-US"; + + private const string TIMEZONE_KEY = "timeZone"; + private const string FORMAT_KEY = "format"; + private const string CULTURE_KEY = "culture"; + + private readonly TimeZoneInfo _timeZoneInfo; + private readonly CancellationToken _cancellationToken; + + + /// + /// Initializes a new instance of the class. + /// + public Time(ILogger logger, IContext context, ExecuteScriptV2Settings settings, + CancellationToken cancellationToken) + { + _cancellationToken = cancellationToken; + + _timeZoneInfo = BotTimeZone.GetTimeZone(logger, context, settings); + } + + /// + /// Parses the date to a DateTime object using the time timezone info. + /// + /// + /// + /// + public DateTime ParseDate(string date, IScriptObject options = null) + { + var timezoneOption = options?[TIMEZONE_KEY] as string; + + var timeZoneInfo = (timezoneOption?.IsNullOrEmpty() ?? true) + ? _timeZoneInfo + : TimeZoneInfo.FindSystemTimeZoneById(timezoneOption); + + if (!(options?[FORMAT_KEY] is string format)) + { +#pragma warning disable S6580 - Use a format provider when parsing date and time. + if (DateTime.TryParse(date, out var parsedDate)) +#pragma warning restore S6580 + { + return parsedDate.Kind == DateTimeKind.Unspecified + ? + // Convert the parsed DateTimeOffset to the desired time zone + TimeZoneInfo.ConvertTime(parsedDate, timeZoneInfo, _timeZoneInfo) + : parsedDate; + } + + format = DEFAULT_TIME_FORMAT; + } + + var cultureOption = options?[CULTURE_KEY] as string; + var culture = new CultureInfo((cultureOption?.IsNullOrEmpty() ?? true) + ? DEFAULT_CULTURE_INFO + : cultureOption); + + // Parse the date string to a DateTimeOffset object + if (!DateTime.TryParseExact(date, + format.IsNullOrEmpty() ? DEFAULT_TIME_FORMAT : format, culture, + DateTimeStyles.None, out var parsedDateOffset)) + { + throw new ArgumentException($"Invalid date format ({format}) to parse: {date}", + nameof(date)); + } + + // Convert the parsed DateTimeOffset to the desired time zone + return parsedDateOffset.Kind == DateTimeKind.Unspecified + ? TimeZoneInfo.ConvertTime(parsedDateOffset, timeZoneInfo, _timeZoneInfo) + : parsedDateOffset; + } + + /// + /// Converts a DateTime object to a string using the time timezone info. + /// + /// The date to convert. + /// The options to parse. + /// + public string DateToString(DateTime date, IScriptObject options = null) + { + // Convert the DateTime object to a DateTimeOffset object + var convertedDate = new DateTimeOffset(date); + + return DateOffsetToString(convertedDate, options); + } + + /// + /// Converts a DateTimeOffset object to a string using the time timezone info. + /// + /// The date to convert. + /// The options to parse. + /// + public string DateOffsetToString(DateTimeOffset date, + IScriptObject options = null) + { + var timezoneOption = options?[TIMEZONE_KEY] as string; + + var timeZoneInfo = (timezoneOption?.IsNullOrEmpty() ?? true) + ? _timeZoneInfo + : TimeZoneInfo.FindSystemTimeZoneById(timezoneOption); + + // Convert the DateTimeOffset to the desired time zone + var convertedDateInTimeZone = TimeZoneInfo.ConvertTime(date, timeZoneInfo); + + var formatOption = options?[FORMAT_KEY] as string; + + // Return the string representation of the converted DateTimeOffset + return convertedDateInTimeZone.ToString((formatOption?.IsNullOrEmpty() ?? true) + ? DEFAULT_TIME_FORMAT + : formatOption); + } + + /// + /// Sleeps for the specified millisecondsDelay. + /// + /// + public void Sleep(int millisecondsDelay) + { + var task = Task.Delay(millisecondsDelay, _cancellationToken); + try + { + task.Wait(_cancellationToken); + } + catch when (task.IsCanceled) + { + throw new TimeoutException("Script execution timed out"); + } + } + } +} \ No newline at end of file diff --git a/src/Take.Blip.Builder/Actions/ExecuteScriptV2/Functions/TimeExtensions.cs b/src/Take.Blip.Builder/Actions/ExecuteScriptV2/Functions/TimeExtensions.cs new file mode 100644 index 00000000..450468ff --- /dev/null +++ b/src/Take.Blip.Builder/Actions/ExecuteScriptV2/Functions/TimeExtensions.cs @@ -0,0 +1,27 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using Microsoft.ClearScript; + +namespace Take.Blip.Builder.Actions.ExecuteScriptV2.Functions +{ + /// + /// Workaround for overload methods: https://github.com/microsoft/ClearScript/issues/432#issuecomment-1289007466 + /// + [SuppressMessage("ReSharper", "UnusedMember.Global")] + [ExcludeFromCodeCoverage] + public static class TimeExtensions + { + /// + public static string DateToString(this Time time, DateTime date, Undefined _) + { + return time.DateToString(date); + } + + /// + public static DateTime ParseDate(this Time time, string date, Undefined _) + { + return time.ParseDate(date); + } + } +} \ No newline at end of file diff --git a/src/Take.Blip.Builder/Actions/ExecuteScriptV2/LowerCaseMembersLoader.cs b/src/Take.Blip.Builder/Actions/ExecuteScriptV2/LowerCaseMembersLoader.cs new file mode 100644 index 00000000..ec956005 --- /dev/null +++ b/src/Take.Blip.Builder/Actions/ExecuteScriptV2/LowerCaseMembersLoader.cs @@ -0,0 +1,33 @@ +using System.Linq; +using System.Reflection; +using Microsoft.ClearScript; + +namespace Take.Blip.Builder.Actions.ExecuteScriptV2 +{ + /// + /// Global transformation to use lower case .NET members in script + /// + public class LowerCaseMembersLoader : CustomAttributeLoader + { + /// + /// Loads custom attributes from a resource using lower case member names + /// + /// + /// + /// + /// + public override T[] LoadCustomAttributes(ICustomAttributeProvider resource, bool inherit) + { + var declaredAttributes = base.LoadCustomAttributes(resource, inherit); + if (!declaredAttributes.Any() && typeof(T) == typeof(ScriptMemberAttribute) && + resource is MemberInfo member) + { + var lowerCamelCaseName = + char.ToLowerInvariant(member.Name[0]) + member.Name.Substring(1); + return new[] { new ScriptMemberAttribute(lowerCamelCaseName) } as T[]; + } + + return declaredAttributes; + } + } +} \ No newline at end of file diff --git a/src/Take.Blip.Builder/Actions/ExecuteScriptV2/README.md b/src/Take.Blip.Builder/Actions/ExecuteScriptV2/README.md new file mode 100644 index 00000000..aaed86a5 --- /dev/null +++ b/src/Take.Blip.Builder/Actions/ExecuteScriptV2/README.md @@ -0,0 +1,376 @@ +Using ClearScript V8 to execute JavaScript as a new action. + +It doesn't changes the behavior of the current ExecuteScriptAction. + +The new implementation allow users to use recent ECMAScript definitions with way less restrictions. + +Additionally, users can execute HTTP request inside the JavaScript with a custom `request.fetchAsync` API. + +The new action also deals with user returns and parse it manually to store in the variable, awaiting any promises used by the JS script. + +Code coverage: 87%: +![image](https://github.com/takenet/blip-sdk-csharp/assets/10624972/65ad2db3-e4d0-42bd-b9f9-1572b78dfe67) + +Benchmark: + +| Method | Mean | Error | StdDev | Gen0 | Gen1 | Allocated | +|---------------------------- |-------------:|----------:|----------:|---------:|---------:|------------:| +| ExecuteScriptV1SimpleScript | 27.90 us | 0.170 us | 0.159 us | 2.8687 | 0.7324 | 35.36 KB | +| ExecuteScriptV1JsonScript | 104.94 us | 0.208 us | 0.162 us | 8.0566 | 2.0752 | 100.15 KB | +| ExecuteScriptV1MathScript | 288.78 us | 0.838 us | 0.700 us | 20.5078 | 5.3711 | 251.85 KB | +| ExecuteScriptV2SimpleScript | 1,435.43 us | 11.332 us | 10.600 us | 3.9063 | 1.9531 | 53.99 KB | +| ExecuteScriptV2JsonScript | 1,483.13 us | 26.174 us | 23.203 us | 3.9063 | 1.9531 | 53.99 KB | +| ExecuteScriptV2MathScript | 1,527.14 us | 21.611 us | 20.215 us | 3.9063 | 1.9531 | 54.03 KB | +| ExecuteScriptV2LoopScript | 1,545.27 us | 19.645 us | 18.376 us | 3.9063 | 1.9531 | 54.54 KB | +| ExecuteScriptV1LoopScript | 12,339.65 us | 64.323 us | 60.168 us | 859.3750 | 203.1250 | 10697.74 KB | + +> The scripts used for the tests are in [here](https://github.com/takenet/blip-sdk-csharp/blob/99118c1cf06822546004a7610aafc6a930f1b3c7/src/Take.Blip.Builder.Benchmark/Actions/Settings.cs). +> +> I had to remove the limt of max statements to execute the loop script in the V1 version, because it was getting blocked. + +Conclusions I made from the benchmark: +- `Execution Time`: V1 is consistently faster than V2 depending on the complexity of the JS script. V2 seems to have a constant overhead regardless of script complexity, which can be good for us to have our system stable regardless of what our users create. + +- `Memory Allocation`: V2 allocates less memory (Gen0, Gen1, Allocated) compared to V1 and seems to have better memory management. V1 only allocated less memory executing the simplest script, but allocated 10MB of memory for a 1000-interactions loop doing absolutelly nothing, which can be the reason we had to set the MaxStatements in the past. + +In summary I think V2 is more stable than V1 but it always take some time to configure and execute. In the future we may need to investigate what in the ExecuteScriptV2Action is spending more time. + +Removing the RegisterFunctions and the result ScriptObjectConverter just to make sure it is not affecting the results: + +| Method | Mean | Error | StdDev | Gen0 | Gen1 | Allocated | +|---------------------------- |-------------:|-----------:|-----------:|---------:|---------:|------------:| +| ExecuteScriptV1SimpleScript | 28.80 us | 0.077 us | 0.065 us | 2.8687 | 0.7324 | 35.36 KB | +| ExecuteScriptV1JsonScript | 101.78 us | 1.200 us | 1.122 us | 8.0566 | 2.0752 | 100.15 KB | +| ExecuteScriptV1MathScript | 290.85 us | 2.210 us | 2.067 us | 20.5078 | 5.3711 | 251.85 KB | +| ExecuteScriptV2SimpleScript | 1,267.44 us | 16.294 us | 15.242 us | 1.9531 | - | 26.7 KB | +| ExecuteScriptV2JsonScript | 1,356.35 us | 26.873 us | 40.223 us | 1.9531 | - | 26.72 KB | +| ExecuteScriptV2MathScript | 1,388.88 us | 19.683 us | 20.213 us | 1.9531 | - | 26.82 KB | +| ExecuteScriptV2LoopScript | 1,390.48 us | 20.609 us | 19.277 us | 1.9531 | - | 27.24 KB | +| ExecuteScriptV1LoopScript | 11,653.97 us | 125.647 us | 117.530 us | 859.3750 | 218.7500 | 10697.74 KB | + +For now I think the memory optimizations may have a good impact in our systems, even taking 1~1.5ms more to execute scripts. + +# Features + +The following documents all the functions available to use inside the JS: + +# Exceptions + +The new version have a new settings to capture all exceptions when executing the script. + +When capturing exceptions, you may store the exception message in a variable to handle it in the flow. + +Our internal exceptions are also captured but the message may be redacted for security reasons. A trace id will be provided in these casees and may be used to get more information about the exception logged by the system. + +We also add a warning to the trace execution with the exception message, so you can see the error in the trace (in the Beholder extension when using Blip's Builder, for example). + +If the variable name to store exception is not provided, the exception will be captured, the trace will have a warning message and the script will continue to execute normally without setting the variable. + +```csharp +/// +/// If the script should capture all exceptions instead of throwing them. +/// +public bool CaptureExceptions { get; set; } + +/// +/// The variable to store the exception message if CaptureExceptions is true. +/// +public string ExceptionVariable { get; set; } +``` + +# Time Manipulation Limitations + +Although the new implementation supports more recent time manipulation functions like `dateVariable.toLocaleString('pt-BR', { timeZone: 'America/Sao_Paulo' })`, we have some limitations with the `ExecuteScriptV2Action`. + +## Limitations: + +- Differently from the `ExecuteScriptAction`, we do not set the local timezone for the script engine based on bot's configured timezone and only modified only three functions from date's prototype to match bot's timezone: `Date.prototype.toDateString`, `Date.prototype.toTimeString` and `Date.prototype.toString`. It will use, when available (`builder:#localTimeZone` in the flow configuration`), the timezone of the configured bot. + +The problem is using the native constructor to parse strings without timezone in its format. It will use the local server engine timezone by default. Because of that we suggest you to use `time.parseDate` instead. + +Example: + +``` +// Instead of this: +const date = new Date('2021-01-01T00:00:10'); // will use server's local timezone +// use this: +const date = time.parseDate('2021-01-01T00:00:10'); // will use bot's timezone or fixed America/Sao_Paulo if not available. +``` + +- If you return a `Date` object in the script result, it will be stored in the context variable with the following format: `yyyy-MM-dd'T'HH:mm:ss.fffffffK`, which is the same of the default format used by the `time.dateToString` helper. + If you need to parse the date in another script using the variable as input, you have two options: + - Parse the date using the format above with javascript's native date constructor, since it contains the timezone in the string. + - Parse the date using our helper `time.parseDate` documented below. + - Return the string representation of the date in the first script with your own desired format using `time.dateToString` or native's date formats and then parse it in the other script you are using it as variable. Make sure to include the timezone in the string format if you choose a custom format. + +> If you want to convert the date to string format with custom timezone, you may use `date.toLocaleString` from native JS and use the options to configure the timezone yourself, or our helper `time.dateToString`, both accepts the timeZone as an option. +> +> If you want to parse a string representation of a date that doesn't includes a timezone, but want to specify a custom timezone, use our `time.parseDate` helper and pass the `timeZone` in the options. + +Date and time helpers: + +> To configure a bot's timezone, the flow configuration must have the following key: `builder:#localTimeZone`. +> +> Also, the action settings `LocalTimeZoneEnabled` must be `true`. + +### ParseDate + +`time.parseDate(string date, object? options)` + +Function to parse a date string to a DateTime object. + +It receives two parameters, with the last one being optional: +- `date`: The date string to be parsed. +- `options`: optional parameter to configure options when parsing the date. Available options: + - `format`: Optional parameter to set the format of the date string. + If not provided it will try to infer the format, which may fail depending on the string. + - `culture`: Optional parameter to infer the culture used in the string format. + It will use `en-US` if not set. + - `timeZone`: Optional parameter to define which time zone should be used if the string doesn't includes the timezone in the format. If not set it will try to use bot's configured timezone or, if not available, `America/Sao_Paulo`. + +`time.parseDate` returns a JS Date object. + +Examples: + +```js +const date = time.parseDate('2021-01-01T19:01:01.0000001+08:00'); + +const date = time.parseDate('01/02/2021', {format: 'MM/dd/yyyy'}); + +const date = time.parseDate('2021-01-01 19:01:01', {format:'yyyy-MM-dd HH:mm:ss', timeZone: 'America/New_York'); + +const date = time.parseDate('01/01/2021', {format: 'dd/MM/yyyy', culture: 'pt-BR'}); +``` + +> After parsing the date, it will return a JS Date object, and you are freely to manipulate it as you want. + +### DateToString + +`time.dateToString(Date date, object? options)` + +Function to convert a Date object to a string using the bot's configured timezone or `America/Sao_Paulo` if not set. + +It receives two parameters: +- `date`: The Date object to be converted. +- `options`: optional parameter to configure options when formatting the date. Available options: + - `format`: Optional parameter to set the format of the date string. + If not provided it will use `yyyy-MM-dd'T'HH:mm:ss.fffffffK`. + - `timeZone`: Optional parameter to define which time zone should be used to format the string. If not set it will try to use bot's configured timezone or, if not available, `America/Sao_Paulo`. + +It returns a string. + +Examples: + +```js +const dateString = time.dateToString(new Date()); + +const dateString = time.dateToString(time.parseDate('2021-01-01T19:01:01.0000001+08:00')); + +const dateString = time.dateToString(new Date(), {format: "yyyy-MM-dd"}); + +const dateString = time.dateToString(new Date(), {timeZone: "America/New_York"}); +``` + +### Sleep + +Additionally, we have a helper function to sleep the script execution for a given amount of time: + +`time.sleep(int milliseconds)` + +> Use this with caution, as it will block the script execution for the given amount of time and make your bot's execution slower. +> +> Remember that all the scripts have a maximum execution time and may throw an error if the script takes too long to execute. + +It receives one parameter: +- `milliseconds`: The amount of time to sleep in milliseconds. + +Examples: + +```js +time.sleep(50); +``` + +> It is useful to force your script to timeout and test how your bot behaves when the script takes too long to execute. + +# Context + +We added some functions to interact with the context variables inside the script. + +## Set Variable + +`context.setVariableAsync(string name, object value, TimeSpan? expiration)` + +Async function to set variable to the context. Should be used in async context. + +It receives three parameters, with the last one being optional: +- `name`: The name of the variable to be set. +- `value`: The value to be set. Can be any object that will be serialized to use as the variable value. +- `expiration`: Optional parameter to set the expiration time of the variable. + If not set, the variable will not expire. + - TimeSpan: you can use the TimeSpan class to set the expiration time. + +Examples: + +```js +async function run() { + await context.setVariableAsync('myVariable', 'myValue'); + // Variable value: 'myValue' + + await context.setVariableAsync('myVariable', 'myValue', TimeSpan.fromMinutes(5)); + // Variable value: 'myValue' + + await context.setVariableAsync('myVariable', 100, TimeSpan.fromMilliseconds(100)); + // Variable value: '100' + + await context.setVariableAsync('myVariable', {'complex': true}, TimeSpan.fromMilliseconds(100)); + // Variable value: '{"complex":true}' +} +``` +## Get Variable + +`context.getVariableAsync(string name)` + +Async function to get variable from the context. Should be used in async context. + +Returns an empty string if the variable does not exist. + +It receives one parameter: +- `name`: The name of the variable to be retrieved. + +Examples: + +```js +async function run() { + const emptyVariable = await context.getVariableAsync('myVariable'); + // emptyVariable: '' + + await context.setVariableAsync('myVariable', 'myValue'); + + const myVariable = await context.getVariableAsync('myVariable'); + // myVariable: 'myValue' +} +``` + +## Delete Variable + +`context.deleteVariableAsync(string name)` + +Async function to delete variable from the context. Should be used in async context. + +It receives one parameter: +- `name`: The name of the variable to be deleted. + +Examples: + +```js +async function run() { + await context.setVariableAsync('myVariable', 'myValue'); + + await context.deleteVariableAsync('myVariable'); + + const myVariable = await context.getVariableAsync('myVariable'); + // myVariable: '' +} +``` + +# HTTP Requests + +We added a new fetch API to allow users to make HTTP requests inside the script. + +## Fetch API + +`request.fetchAsync(string url, object? options)` + +An Async function to make HTTP requests inside the script. Should be used in async context. + +It receives two parameters, with the last one being optional: +- `url`: The URL to make the request. +- `options`: Optional parameter to set the request options. It must be a dictionary with the following optional properties: + - `method`: The HTTP method to be used. Default: 'GET'. + - `headers`: The headers to be sent with the request. Default: {}. Header values in the request options can be a string or an array of strings. + - `body`: The body to be sent with the request. Default: null. + +It returns a object with the following properties: +- `status`: The status code of the response. +- `headers`: The headers of the response. It is a dictionary with the header names as keys and the header values as values, where the values are arrays of strings. +- `body`: The body of the response in string format. +- `success`: A boolean indicating if the request was successful (200-299 status code range). + +The response also have the `jsonAsync()` method to parse the body as JSON. + +> If the request takes too long to execute, it will throw an error according to the script timeout, always remember to handle the errors outside the script in your bot. + +Examples: + +```js +async function run() { + const response = await request.fetchAsync('https://jsonplaceholder.typicode.com/todos/1'); + /* response example: + { + status: 200, + headers: { 'key1': ['value1', 'value2'] }, + body: '{"userId": 1, "id": 1, "title": "delectus aut autem", "completed": false}', + success: true + } + */ + + const json = await response.jsonAsync(); + // json: { userId: 1, id: 1, title: 'delectus aut autem', completed: false } +} +``` + +```js +async function run() { + // Body will be serialized to JSON automatically + const response = await request.fetchAsync('https://jsonplaceholder.typicode.com/posts', { method: 'POST', body: { title: 'foo', body: 'bar', userId: 1 } }); + /* response example: + { + status: 201, + headers: { 'key1': ['value1', 'value2'] }, + body: '{"title": "foo", "body": "bar", "userId": 1, "id": 101}', + success: true + } + */ + + const json = await response.jsonAsync(); + // json: { title: 'foo', body: 'bar', userId: 1, id: 101 } +} +``` + +```js +// More on object manipulation +async function run() { + const response = await request.fetchAsync('https://jsonplaceholder.typicode.com/todos/1'); + + const status = response.status; + // status: 200 + + const success = response.success; + // success: true + + const headers = response.headers; + // headers: { 'key1': ['value1', 'value2'] } + + const body = response.body; + // body: '{"userId": 1, "id": 1, "title": "delectus aut autem", "completed": false}' + + const jsonBody = await response.jsonAsync(); + // jsonBody: { userId: 1, id: 1, title: 'delectus aut autem', completed: false } + + // Header manipulation + for (const header of response.headers) { + // do something with header key and value, where value is an array of strings + // key will always be on lower case + const key = header.key; + const values = header.value; + + for (const value of values) { + // do something with the value + } + } + + // Get header with case insensitive key and first value + // Returns undefined if the header does not exist and an array of strings with the values otherwise + const contentType = response.getHeader('content-type'); +} +``` \ No newline at end of file diff --git a/src/Take.Blip.Builder/Actions/ExecuteScriptV2/ScriptEngineExtensions.cs b/src/Take.Blip.Builder/Actions/ExecuteScriptV2/ScriptEngineExtensions.cs new file mode 100644 index 00000000..7e59c020 --- /dev/null +++ b/src/Take.Blip.Builder/Actions/ExecuteScriptV2/ScriptEngineExtensions.cs @@ -0,0 +1,49 @@ +using System; +using System.Threading; +using Microsoft.ClearScript; + +namespace Take.Blip.Builder.Actions.ExecuteScriptV2 +{ + /// + /// Extensions for . + /// + public static class ScriptEngineExtensions + { + private const string DEFAULT_FUNCTION = "run"; + + /// + /// Evaluates the specified code with a timeout. + /// + /// The script engine. + /// The code to evaluate. + /// The function to evaluate. + /// The timeout. + /// The arguments. + /// + /// + public static object ExecuteInvoke(this ScriptEngine engine, string code, + string function = "run", + TimeSpan? timeout = null, params object[] args) + { + using var timer = new Timer(_ => engine.Interrupt()); + + try + { + timer.Change(timeout ?? TimeSpan.FromSeconds(5), + TimeSpan.FromMilliseconds(Timeout.Infinite)); + + engine.Execute(code); + + var result = args != null + ? engine.Invoke(function ?? DEFAULT_FUNCTION, args) + : engine.Invoke(function ?? DEFAULT_FUNCTION); + + return result; + } + catch (ScriptInterruptedException ex) + { + throw new TimeoutException("Script execution timed out", ex); + } + } + } +} \ No newline at end of file diff --git a/src/Take.Blip.Builder/Actions/ExecuteScriptV2/ScriptObjectConverter.cs b/src/Take.Blip.Builder/Actions/ExecuteScriptV2/ScriptObjectConverter.cs new file mode 100644 index 00000000..c878eecd --- /dev/null +++ b/src/Take.Blip.Builder/Actions/ExecuteScriptV2/ScriptObjectConverter.cs @@ -0,0 +1,148 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.ClearScript; +using Newtonsoft.Json; +using Take.Blip.Builder.Actions.ExecuteScriptV2.Functions; + +namespace Take.Blip.Builder.Actions.ExecuteScriptV2 +{ + /// + /// Utility converter to convert javascript result to c# representation on string. + /// + [SuppressMessage("ReSharper", "SuggestBaseTypeForParameter")] + public static class ScriptObjectConverter + { + /// + /// Converts the data to string representation. + /// + /// + /// + /// + /// + public static async Task ToStringAsync(object data, Time time, + CancellationToken cancellationToken) + { + var resultData = await ConvertAsync(data, time, cancellationToken); + + return resultData switch + { + DateTime dateTime => time.DateToString(dateTime), + DateTimeOffset dateTime => time.DateOffsetToString(dateTime), + string str => str, + double @double => @double.ToString("R"), + long @long => @long.ToString(), + int @int => @int.ToString(), + float @float => @float.ToString("R"), + bool @bool => @bool ? "true" : "false", + _ => JsonConvert.SerializeObject(resultData) + }; + } + + /// + /// Converts script result to c# object. + /// + /// + /// + /// + /// + public static async Task ConvertAsync(object data, Time time, + CancellationToken cancellationToken) + { + try + { + switch (data) + { + case DateTime dateTime: + return time.DateToString(dateTime); + + case DateTimeOffset dateTimeOffset: + return time.DateOffsetToString(dateTimeOffset); + + case ScriptObject scriptObject when scriptObject.PropertyNames.Any(): + return await ToDictionary(scriptObject, time, + cancellationToken); + + case ScriptObject scriptObject: + return scriptObject.PropertyIndices.Any() + ? await ToList(scriptObject, time, cancellationToken) + : data; + + case Task task: + { + var delayTask = Task.Delay(Timeout.Infinite, cancellationToken); + var completedTask = await Task.WhenAny(task, delayTask); + if (completedTask == delayTask) + { + // The delay task completed first because the cancellation token was triggered. + throw new OperationCanceledException(cancellationToken); + } + + if (completedTask.IsFaulted) + { + throw new ScriptEngineException( + "An error occurred while executing the script.", task.Exception); + } + + if (completedTask.IsCanceled) + { + throw new OperationCanceledException( + "The script execution was canceled."); + } + + return await ConvertAsync(((Task)completedTask).Result, + time, + cancellationToken); + } + default: + return data; + } + } + catch (ObjectDisposedException ex) + { + throw new ScriptEngineException("Can not access disposed variable", ex); + } + } + + private static async Task> ToList(ScriptObject scriptObject, + Time time, + CancellationToken cancellationToken) + { + var indexes = scriptObject.PropertyIndices.ToList(); + var results = new List(); + + foreach (var index in indexes) + { + var result = await ConvertAsync(scriptObject.GetProperty(index), + time, + cancellationToken); + + results.Add(result); + } + + return results; + } + + private static async Task> ToDictionary( + ScriptObject scriptObject, Time time, + CancellationToken cancellationToken) + { + var propertyNames = scriptObject.PropertyNames; + + var dictionary = new Dictionary(); + + foreach (var propertyName in propertyNames) + { + dictionary[propertyName] = await ConvertAsync( + scriptObject.GetProperty(propertyName), + time, + cancellationToken); + } + + return dictionary; + } + } +} \ No newline at end of file diff --git a/src/Take.Blip.Builder/Hosting/ContainerExtensions.cs b/src/Take.Blip.Builder/Hosting/ContainerExtensions.cs index db9745f4..1589cfb3 100644 --- a/src/Take.Blip.Builder/Hosting/ContainerExtensions.cs +++ b/src/Take.Blip.Builder/Hosting/ContainerExtensions.cs @@ -8,6 +8,7 @@ using Take.Blip.Builder.Actions.CreateTicket; using Take.Blip.Builder.Actions.DeleteVariable; using Take.Blip.Builder.Actions.ExecuteScript; +using Take.Blip.Builder.Actions.ExecuteScriptV2; using Take.Blip.Builder.Actions.ExecuteTemplate; using Take.Blip.Builder.Actions.ManageList; using Take.Blip.Builder.Actions.MergeContact; @@ -82,6 +83,7 @@ private static Container RegisterBuilderActions(this Container container) new[] { typeof(ExecuteScriptAction), + typeof(ExecuteScriptV2Action), typeof(SendMessageAction), typeof(SendMessageFromHttpAction), typeof(SendRawMessageAction), diff --git a/src/Take.Blip.Builder/Hosting/ConventionsConfiguration.cs b/src/Take.Blip.Builder/Hosting/ConventionsConfiguration.cs index 2446aebe..d3d20ff0 100644 --- a/src/Take.Blip.Builder/Hosting/ConventionsConfiguration.cs +++ b/src/Take.Blip.Builder/Hosting/ConventionsConfiguration.cs @@ -31,6 +31,12 @@ public class ConventionsConfiguration : IConfiguration public long ExecuteScriptLimitMemoryWarning => 10_000_000; // Nearly 10MB + public long ExecuteScriptV2MaxRuntimeHeapSize => 100_000_000; // Nearly 100MB + + public long ExecuteScriptV2MaxRuntimeStackUsage => 50_000_000; // Nearly 50MB + + public TimeSpan ExecuteScriptV2Timeout => TimeSpan.FromSeconds(10); + public TimeSpan ExecuteScriptTimeout => TimeSpan.FromSeconds(5); public string InternalUris => "http.msging.net"; diff --git a/src/Take.Blip.Builder/Hosting/IConfiguration.cs b/src/Take.Blip.Builder/Hosting/IConfiguration.cs index 4d529d88..84ef1d7a 100644 --- a/src/Take.Blip.Builder/Hosting/IConfiguration.cs +++ b/src/Take.Blip.Builder/Hosting/IConfiguration.cs @@ -36,6 +36,12 @@ public interface IConfiguration TimeSpan ExecuteScriptTimeout { get; } + TimeSpan ExecuteScriptV2Timeout { get; } + int MaximumInputExpirationLoop { get; } + + long ExecuteScriptV2MaxRuntimeHeapSize { get; } + + long ExecuteScriptV2MaxRuntimeStackUsage { get; } } } \ No newline at end of file diff --git a/src/Take.Blip.Builder/Take.Blip.Builder.csproj b/src/Take.Blip.Builder/Take.Blip.Builder.csproj index 69ec76ad..a07f8551 100644 --- a/src/Take.Blip.Builder/Take.Blip.Builder.csproj +++ b/src/Take.Blip.Builder/Take.Blip.Builder.csproj @@ -8,6 +8,10 @@ + + + + diff --git a/src/Take.Blip.Client.sln b/src/Take.Blip.Client.sln index 75eaf177..3bb316d2 100644 --- a/src/Take.Blip.Client.sln +++ b/src/Take.Blip.Client.sln @@ -38,6 +38,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MessageTypes", "Samples\Mes EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HelpDesk", "Samples\HelpDesk\HelpDesk.csproj", "{10DC6EDE-CF09-4D37-A63C-41E035C215E5}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Take.Blip.Builder.Benchmark", "Take.Blip.Builder.Benchmark\Take.Blip.Builder.Benchmark.csproj", "{80384664-856D-4C98-AE9F-22AD1078A7D0}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -96,6 +98,10 @@ Global {10DC6EDE-CF09-4D37-A63C-41E035C215E5}.Debug|Any CPU.Build.0 = Debug|Any CPU {10DC6EDE-CF09-4D37-A63C-41E035C215E5}.Release|Any CPU.ActiveCfg = Release|Any CPU {10DC6EDE-CF09-4D37-A63C-41E035C215E5}.Release|Any CPU.Build.0 = Release|Any CPU + {80384664-856D-4C98-AE9F-22AD1078A7D0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {80384664-856D-4C98-AE9F-22AD1078A7D0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {80384664-856D-4C98-AE9F-22AD1078A7D0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {80384664-856D-4C98-AE9F-22AD1078A7D0}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE