using Elsa.Expressions.JavaScript.Contracts;
using Elsa.Expressions.JavaScript.Options;
using Elsa.Expressions.Models;
using Elsa.Extensions;
using Elsa.Testing.Shared;
using Jint;
using Jint.Runtime;
using Microsoft.Extensions.DependencyInjection;
using Xunit;
using Xunit.Abstractions;
namespace Elsa.JavaScript.IntegrationTests;
///
/// Verifies that a runaway JavaScript expression cannot occupy the calling thread indefinitely.
///
public class ExecutionConstraintTests(ITestOutputHelper testOutputHelper)
{
private const string InfiniteLoop = "while (true) {}";
///
/// Tests whose subject is a constraint other than the timeout still register a timeout, so that a regression
/// in the constraint under test fails that one test instead of leaving an infinite loop running until the CI
/// job is killed. It is orders of magnitude longer than those constraints need — each of them aborts within
/// milliseconds — so it cannot itself become a source of flaky failures on a loaded machine, and
/// reports a trip as a failsafe trip rather than as a
/// confusing exception type mismatch.
///
private static readonly TimeSpan FailsafeTimeout = TimeSpan.FromSeconds(30);
[Fact(DisplayName = "An expression that never returns is aborted by the execution timeout")]
public async Task ExecutionTimeoutAbortsRunawayExpression()
{
// No failsafe needed: the timeout is the subject here, so the test is bounded by the thing it asserts.
var services = BuildServices(options => options.ExecutionTimeout = TimeSpan.FromMilliseconds(250));
await Assert.ThrowsAsync(() => EvaluateAsync(services, InfiniteLoop));
}
[Fact(DisplayName = "An expression that never returns is aborted when the cancellation token is signalled")]
public async Task CancellationAbortsRunawayExpression()
{
var services = BuildServices(options => options.ExecutionTimeout = FailsafeTimeout);
using var cancellationTokenSource = new CancellationTokenSource();
// The script signals the token itself, so cancellation is guaranteed to land after the engine has been
// built and while the expression is running. A wall-clock timer would instead race engine construction
// and could fault the setup rather than the script on a loaded machine.
await AssertAbortedByAsync(() => EvaluateAsync(
services,
"cancel(); " + InfiniteLoop,
engine => engine.SetValue("cancel", (Action)cancellationTokenSource.Cancel),
cancellationTokenSource.Token));
}
[Fact(DisplayName = "An expression that exceeds the statement limit is aborted")]
public async Task StatementLimitAbortsRunawayExpression()
{
var services = BuildServices(options =>
{
options.ExecutionTimeout = FailsafeTimeout;
options.MaxStatements = 100;
});
await AssertAbortedByAsync(() => EvaluateAsync(services, InfiniteLoop));
}
[Fact(DisplayName = "An expression that allocates without bound is aborted by the memory limit")]
public async Task MemoryLimitAbortsAllocationHeavyExpression()
{
var services = BuildServices(options =>
{
options.ExecutionTimeout = FailsafeTimeout;
options.MemoryLimit = 4 * 1024 * 1024;
});
// The limit is checked between statements, so the script has to allocate in visible steps. Doubling the
// string crosses any limit within a couple of dozen iterations, which keeps the test fast and bounds how
// far past the limit the process can get before the check fires.
await AssertAbortedByAsync(() => EvaluateAsync(services, "var s = 'x'; while (true) { s += s; }"));
}
[Fact(DisplayName = "An expression that recurses without bound is aborted")]
public async Task RecursionLimitAbortsRunawayExpression()
{
var services = BuildServices(options =>
{
options.ExecutionTimeout = FailsafeTimeout;
options.MaxRecursionDepth = 32;
});
await AssertAbortedByAsync(() => EvaluateAsync(services, "function f() { return f(); } return f();"));
}
[Fact(DisplayName = "A well-behaved expression is unaffected by the default constraints")]
public async Task DefaultConstraintsDoNotAffectNormalExpressions()
{
var services = BuildServices(_ => { });
Assert.Equal("3", await EvaluateAsync(services, "return '' + (1 + 2);"));
}
private IServiceProvider BuildServices(Action configure)
{
return new TestApplicationBuilder(testOutputHelper)
.ConfigureElsa(elsa => elsa.UseJavaScript(configure))
.Build();
}
private static async Task EvaluateAsync(IServiceProvider services, string script, Action? configureEngine = null, CancellationToken cancellationToken = default)
{
var evaluator = services.GetRequiredService();
var context = new ExpressionExecutionContext(services, new());
return (string?)await evaluator.EvaluateAsync(script, typeof(string), context, configureEngine: configureEngine, cancellationToken: cancellationToken);
}
///
/// Asserts that the expression was aborted by , distinguishing that outcome
/// from a trip of the failsafe execution timeout that guards the test against running unbounded.
///
private static async Task AssertAbortedByAsync(Func evaluate) where TException : Exception
{
var exception = await Record.ExceptionAsync(evaluate);
if (exception is null)
Assert.Fail($"Expected the expression to be aborted by {typeof(TException).Name}, but it ran to completion.");
if (exception is TimeoutException)
Assert.Fail($"The {FailsafeTimeout.TotalSeconds:0} second failsafe execution timeout fired before {typeof(TException).Name} was thrown: the constraint under test did not abort the expression.");
Assert.IsType(exception);
}
}