Browse Source

Temp

pull/1330/head
Sebastian Stehle 2 weeks ago
parent
commit
30ff0eb5bf
  1. 9
      backend/src/Squidex.Domain.Apps.Core.Operations/ConvertContent/UpdateValues.cs
  2. 10
      backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Extensions/EventJintExtension.cs
  3. 6
      backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/EngineExtensions.cs
  4. 33
      backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/IAsyncScript.cs
  5. 30
      backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/IScript.cs
  6. 57
      backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/IScriptEngine.cs
  7. 171
      backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Internal/AsyncScriptExecutionContext.cs
  8. 50
      backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Internal/JintExtensions.cs
  9. 366
      backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/JintScript.cs
  10. 193
      backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/JintScriptEngine.cs
  11. 182
      backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ScriptExecutionContext.cs
  12. 2
      backend/src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj
  13. 44
      backend/src/Squidex.Domain.Apps.Entities/Assets/AssetsJintExtension.cs
  14. 35
      backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/Steps/ScriptAsset.cs
  15. 18
      backend/src/Squidex.Domain.Apps.Entities/Contents/ContentsJintExtension.cs
  16. 31
      backend/src/Squidex.Domain.Apps.Entities/Contents/Counter/CounterJintExtension.cs
  17. 35
      backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/ScriptContent.cs
  18. 24
      backend/src/Squidex.Domain.Apps.Entities/Contents/ReferencesJintExtension.cs
  19. 2
      backend/src/Squidex.Domain.Apps.Entities/Rules/RuleEnqueuer.cs
  20. 24
      backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/JintScriptEngineHelperTests.cs
  21. 221
      backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/JintScriptEngineTests.cs
  22. 19
      backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/MockupHttpHandler.cs
  23. 8
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetsJintExtensionTests.cs
  24. 24
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/Queries/ScriptAssetTests.cs
  25. 4
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentsJintExtensionTests.cs
  26. 2
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Counter/CounterJintExtensionTests.cs
  27. 2
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/DomainObject/ContentDomainObjectTests.cs
  28. 24
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ScriptContentTests.cs
  29. 8
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ReferencesJintExtensionTests.cs
  30. 2
      backend/tests/Squidex.Web.Tests/Scripting/HttpRequestJintExtensionTests.cs

9
backend/src/Squidex.Domain.Apps.Core.Operations/ConvertContent/UpdateValues.cs

@ -14,6 +14,7 @@ namespace Squidex.Domain.Apps.Core.ConvertContent;
public sealed class UpdateValues(ContentData existingData, IScriptEngine scriptEngine, bool canUnset) : IContentValueConverter, IContentDataConverter
{
private static readonly ScriptOptions Options = new ScriptOptions { Readonly = true };
private ScriptVars? vars;
public void ConvertDataBefore(Schema schema, ContentData source)
@ -32,8 +33,6 @@ public sealed class UpdateValues(ContentData existingData, IScriptEngine scriptE
if (Updates.IsUpdate(value, out var expression))
{
var options = new ScriptOptions { Readonly = true };
// Reuse the vars to save allocations.
vars ??= new ScriptVars
{
@ -44,7 +43,7 @@ public sealed class UpdateValues(ContentData existingData, IScriptEngine scriptE
vars["$self"] = value;
// Put the expression in brackets to return an object directly.
var result = scriptEngine.Execute(vars, $"({expression})", options);
var result = scriptEngine.Execute(vars, $"({expression})", Options);
if (result.Value is JsonObject obj)
{
@ -93,8 +92,6 @@ public sealed class UpdateValues(ContentData existingData, IScriptEngine scriptE
return (false, source);
}
var options = new ScriptOptions { Readonly = true };
// Reuse the vars to save allocations.
vars ??= new ScriptVars
{
@ -105,7 +102,7 @@ public sealed class UpdateValues(ContentData existingData, IScriptEngine scriptE
vars["$self"] = obj;
// Put the expression in brackets to return an object directly.
var result = scriptEngine.Execute(vars, $"({expression})", options);
var result = scriptEngine.Execute(vars, $"({expression})", Options);
return (false, result);
}

10
backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Extensions/EventJintExtension.cs

@ -68,13 +68,11 @@ public sealed class EventJintExtension(IUrlGenerator urlGenerator) : IJintExtens
public void Extend(Engine engine)
{
var context = engine.GetContext();
engine.SetValue("console", FlowConsoleWrapper.Instance);
engine.SetValue("contentAction", new EventDelegate(() =>
{
if (context.TryGetValue("event", out var temp) && temp is EnrichedContentEvent contentEvent)
if (engine.TryGetVar<EnrichedContentEvent>("event", out var contentEvent))
{
return contentEvent.Status.ToString();
}
@ -84,7 +82,7 @@ public sealed class EventJintExtension(IUrlGenerator urlGenerator) : IJintExtens
engine.SetValue("contentUrl", new EventDelegate(() =>
{
if (context.TryGetValue("event", out var temp) && temp is EnrichedContentEvent contentEvent)
if (engine.TryGetVar<EnrichedContentEvent>("event", out var contentEvent))
{
return urlGenerator.ContentUI(contentEvent.AppId, contentEvent.SchemaId, contentEvent.Id);
}
@ -94,7 +92,7 @@ public sealed class EventJintExtension(IUrlGenerator urlGenerator) : IJintExtens
engine.SetValue("assetContentSlugUrl", new EventDelegate(() =>
{
if (context.TryGetValue("event", out var temp) && temp is EnrichedAssetEvent assetEvent)
if (engine.TryGetVar<EnrichedAssetEvent>("event", out var assetEvent))
{
return urlGenerator.AssetContent(assetEvent.AppId, assetEvent.FileName.Slugify());
}
@ -104,7 +102,7 @@ public sealed class EventJintExtension(IUrlGenerator urlGenerator) : IJintExtens
var assetUrl = new EventDelegate(() =>
{
if (context.TryGetValue("event", out var temp) && temp is EnrichedAssetEvent assetEvent)
if (engine.TryGetVar<EnrichedAssetEvent>("event", out var assetEvent))
{
return urlGenerator.AssetContent(assetEvent.AppId, assetEvent.Id.ToString());
}

6
backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/EngineExtensions.cs

@ -5,6 +5,7 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Diagnostics.CodeAnalysis;
using Jint;
namespace Squidex.Domain.Apps.Core.Scripting;
@ -16,6 +17,11 @@ public static class EngineExtensions
return ScriptExecutionContext.GetContext(engine);
}
public static bool TryGetVar<T>(this Engine engine, string key, [MaybeNullWhen(false)] out T value)
{
return ScriptExecutionContext.GetContext(engine).TryGetValueIfExists(key, out value);
}
public static void Schedule(this Engine engine, Func<CancellationToken, Task> action)
{
ScriptExecutionContext.GetContext(engine).Schedule(action);

33
backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/IAsyncScript.cs

@ -0,0 +1,33 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Infrastructure.Json.Objects;
namespace Squidex.Domain.Apps.Core.Scripting;
public interface IAsyncScript : IDisposable
{
ValueTask<ContentData> TransformAsync(DataScriptVars vars,
CancellationToken ct = default);
ValueTask<JsonValue> ExecuteAsync(ScriptVars vars,
CancellationToken ct = default);
async ValueTask<bool> EvaluateAsync(ScriptVars vars,
CancellationToken ct = default)
{
try
{
return (await ExecuteAsync(vars, ct)).Equals(true);
}
catch
{
return false;
}
}
}

30
backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/IScript.cs

@ -0,0 +1,30 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Infrastructure.Json.Objects;
namespace Squidex.Domain.Apps.Core.Scripting;
public interface IScript : IDisposable
{
ContentData Transform(DataScriptVars vars);
JsonValue Execute(ScriptVars vars);
bool Evaluate(ScriptVars vars)
{
try
{
return Execute(vars).Equals(true);
}
catch
{
return false;
}
}
}

57
backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/IScriptEngine.cs

@ -1,4 +1,4 @@
// ==========================================================================
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
@ -12,19 +12,62 @@ namespace Squidex.Domain.Apps.Core.Scripting;
public interface IScriptEngine
{
Task<JsonValue> ExecuteAsync(ScriptVars vars, string script, ScriptOptions options = default,
CancellationToken ct = default);
IScript CreateScript(string script, ScriptOptions options = default);
IAsyncScript CreateAsyncScript(string script, ScriptOptions options = default);
ContentData Transform(DataScriptVars vars, string script, ScriptOptions options = default)
{
using var compiled = CreateScript(script, options);
Task<ContentData> TransformAsync(DataScriptVars vars, string script, ScriptOptions options = default,
CancellationToken ct = default);
return compiled.Transform(vars);
}
JsonValue Execute(ScriptVars vars, string script, ScriptOptions options = default)
{
using var compiled = CreateScript(script, options);
JsonValue Execute(ScriptVars vars, string script, ScriptOptions options = default);
return compiled.Execute(vars);
}
bool Evaluate(ScriptVars vars, string script, ScriptOptions options = default)
{
try
{
return Execute(vars, script, options).Equals(true);
using var compiled = CreateScript(script, options);
return compiled.Evaluate(vars);
}
catch
{
return false;
}
}
async ValueTask<ContentData> TransformAsync(DataScriptVars vars, string script, ScriptOptions options = default,
CancellationToken ct = default)
{
using var compiled = CreateAsyncScript(script, options);
return await compiled.TransformAsync(vars, ct);
}
async ValueTask<JsonValue> ExecuteAsync(ScriptVars vars, string script, ScriptOptions options = default,
CancellationToken ct = default)
{
using var compiled = CreateAsyncScript(script, options);
return await compiled.ExecuteAsync(vars, ct);
}
async ValueTask<bool> EvaluateAsync(ScriptVars vars, string script, ScriptOptions options = default,
CancellationToken ct = default)
{
try
{
using var compiled = CreateAsyncScript(script, options);
return await compiled.EvaluateAsync(vars, ct);
}
catch
{

171
backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Internal/AsyncScriptExecutionContext.cs

@ -0,0 +1,171 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Jint;
using Squidex.Infrastructure.Tasks;
namespace Squidex.Domain.Apps.Core.Scripting.Internal;
internal sealed class AsyncScriptExecutionContext<T> : ScriptExecutionContext
{
private readonly TaskCompletionSource<CompletedValue?> tcs = new TaskCompletionSource<CompletedValue?>(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly CancellationTokenRegistration cancellationRegistration;
private readonly CancellationToken cancellationToken;
private readonly JintScript script;
private readonly Engine engine;
private int pendingTasks = 1;
private sealed class CompletedValue
{
public T Value { get; init; }
}
public bool IsCompleted
{
get => tcs.Task.IsCompleted;
}
internal AsyncScriptExecutionContext(Engine engine, JintScript script, CancellationToken ct)
: base(engine)
{
this.engine = engine;
// The lock belongs to the script, because the engine is shared between all executions.
this.script = script;
cancellationToken = ct;
// Settle the source on cancellation, so that pending callbacks do not enter the engine anymore.
cancellationRegistration = cancellationToken.Register(static state =>
{
var self = (AsyncScriptExecutionContext<T>)state!;
self.tcs.TrySetCanceled(self.cancellationToken);
},
this);
}
public async Task<T> WaitForCompletionAsync(Func<T> fallback)
{
TryComplete();
try
{
var result = await tcs.Task;
if (result != null)
{
return result.Value;
}
return await script.RunLockedAsync(() => fallback(), cancellationToken);
}
finally
{
await cancellationRegistration.DisposeAsync();
}
}
public void Complete(T value)
{
tcs.TrySetResult(new CompletedValue { Value = value });
}
public override void Fail(Exception exception)
{
TryFail(exception);
}
public override void Schedule(Func<CancellationToken, Task> action)
{
ScheduleCoreAsync(async ct =>
{
await action(ct);
return true;
},
null);
}
public override void Schedule<TResult>(Func<CancellationToken, Task<TResult>> action, Action<TResult>? callback)
{
ScheduleCoreAsync(async ct =>
{
var result = await action(ct);
return result;
},
callback);
}
private void ScheduleCoreAsync<TResult>(Func<CancellationToken, Task<TResult>> action, Action<TResult>? callback)
{
if (IsCompleted)
{
return;
}
async Task ScheduleAsync()
{
TryStart();
try
{
// The action must not touch the engine, so that parallel tasks do not block each other.
var result = await action(cancellationToken);
// The callback converts javascript values and is therefore the only part that needs the lock.
await RunLockedAsync(() => callback?.Invoke(result));
TryComplete();
}
catch (Exception ex)
{
TryFail(ex);
}
}
ScheduleAsync().Forget();
}
private Task<bool> RunLockedAsync(Action action)
{
// The lock is owned by the script, because the engine is shared between all executions.
return script.RunLockedAsync(() =>
{
// Late callbacks must not touch the engine anymore, the next execution might have started.
if (IsCompleted)
{
return true;
}
// The task can take a while, therefore the action gets a fresh timeout.
engine.Constraints.Reset();
action();
// The evaluation does not wait for the promises anymore, therefore the continuations that
// the callback has unblocked have to be executed here, while the lock is still held.
engine.Advanced.ProcessTasks();
return true;
},
cancellationToken);
}
private void TryFail(Exception exception)
{
tcs.TrySetException(exception);
}
private void TryStart()
{
Interlocked.Increment(ref pendingTasks);
}
private void TryComplete(CompletedValue? result = null)
{
if (Interlocked.Decrement(ref pendingTasks) <= 0)
{
tcs.TrySetResult(result);
}
}
}

50
backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Internal/JintExtensions.cs

@ -31,54 +31,4 @@ public static class JintExtensions
return ids;
}
internal static ScriptExecutionContext<T> ExtendWithAsyncFunctions<T>(this ScriptExecutionContext<T> context,
IEnumerable<IJintExtension> extensions)
{
foreach (var extension in extensions)
{
extension.ExtendAsync(context.Engine);
}
return context;
}
internal static ScriptExecutionContext<T> ExtendWithFunctions<T>(this ScriptExecutionContext<T> context,
IEnumerable<IJintExtension> extensions)
{
foreach (var extension in extensions)
{
extension.Extend(context.Engine);
}
return context;
}
internal static ScriptExecutionContext<T> ExtendWithVariables<T>(this ScriptExecutionContext<T> context,
ScriptVars vars,
ScriptOptions options)
{
var engine = context.Engine;
context.CopyFrom(vars);
if (options.AsContext)
{
var contextInstance = new WritableContext(engine, vars);
engine.SetValue("ctx", contextInstance);
engine.SetValue("context", contextInstance);
}
else
{
foreach (var (key, item) in vars)
{
engine.SetValue(key, item);
}
}
engine.SetValue("async", true);
return context;
}
}

366
backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/JintScript.cs

@ -0,0 +1,366 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Diagnostics;
using Acornima;
using Acornima.Ast;
using Jint;
using Jint.Constraints;
using Jint.Native;
using Jint.Native.Promise;
using Jint.Runtime;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Core.Scripting.ContentWrapper;
using Squidex.Domain.Apps.Core.Scripting.Internal;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Json.Objects;
using Squidex.Infrastructure.Translations;
using Squidex.Infrastructure.Validation;
namespace Squidex.Domain.Apps.Core.Scripting;
internal sealed class JintScript : IScript, IAsyncScript, IDisposable
{
// The engine is shared between all executions, therefore only one of them can run at a time.
private readonly SemaphoreSlim executionGate = new SemaphoreSlim(1);
// The engine is also shared with the callbacks of the scheduled tasks, which run on other threads.
private readonly SemaphoreSlim engineLock = new SemaphoreSlim(1);
// Jint only creates the constraint for a token that can actually be cancelled, therefore the engine is
// built with a placeholder that is never cancelled and repointed for each execution.
private readonly CancellationTokenSource placeholder = new CancellationTokenSource();
private readonly CancellationConstraint? cancellation;
private readonly Engine engine;
private readonly Prepared<Script> parsed;
private readonly ScriptOptions scriptOptions;
private readonly JintScriptOptions engineOptions;
private readonly GlobalSnapshot snapshot;
public JintScript(
Prepared<Script> parsed,
ScriptOptions scriptOptions,
JintScriptOptions engineOptions,
IJintExtension[] extensions,
bool allowAsync)
{
this.parsed = parsed;
this.scriptOptions = scriptOptions;
this.engineOptions = engineOptions;
engine = new Engine(options =>
{
options.AddObjectConverter(JintObjectConverter.Instance);
options.AllowOperatorOverloading();
options.AllowClrWrite(!scriptOptions.Readonly);
options.SetTypeConverter(engine => new CustomClrConverter(engine));
options.SetReferencesResolver(NullPropagation.Instance);
options.Strict();
if (!Debugger.IsAttached)
{
options.Constraints.PromiseTimeout = engineOptions.TimeoutPromise;
options.TimeoutInterval(engineOptions.TimeoutScript);
options.CancellationToken(placeholder.Token);
}
});
cancellation = engine.Constraints.Find<CancellationConstraint>();
// The extensions resolve the variables from the context of the current execution, therefore they
// are registered once, even though the variables are different for each execution.
foreach (var extension in extensions)
{
extension.Extend(engine);
}
if (allowAsync)
{
foreach (var extension in extensions)
{
extension.ExtendAsync(engine);
}
}
if (scriptOptions.CanDisallow)
{
engine.AddDisallow();
}
if (scriptOptions.CanReject)
{
engine.AddReject();
}
// The evaluation does not wait for the promises anymore, therefore an unhandled rejection would be
// swallowed. The tracker only fires when nothing handles the rejection.
engine.Advanced.PromiseRejectionTracker += (_, args) =>
{
if (args.Operation == PromiseRejectionOperation.Reject)
{
ScriptExecutionContext.GetContext(engine).Fail(
new JavaScriptException($"Promise was rejected with value {args.Value}."));
}
};
snapshot = engine.Advanced.CaptureGlobalSnapshot();
}
public JsonValue Execute(ScriptVars vars)
{
Guard.NotNull(vars);
return Run(vars, (vars, _) =>
{
JsonValue? completed = null;
engine.SetValue("complete", new Action<JsValue?>(value =>
{
completed ??= JsonMapper.Map(value);
}));
var result = engine.Evaluate(parsed);
return completed ?? JsonMapper.Map(result);
});
}
public ValueTask<JsonValue> ExecuteAsync(ScriptVars vars,
CancellationToken ct = default)
{
Guard.NotNull(vars);
return RunAsync<JsonValue, ScriptVars>(vars, async (vars, context, ct) =>
{
engine.SetValue("complete", new Action<JsValue?>(value =>
{
context.Complete(JsonMapper.Map(value));
}));
// The evaluation holds the lock, but returns without waiting for the pending promises.
var result = await RunLockedAsync(() => engine.Evaluate(parsed), ct);
return await context.WaitForCompletionAsync(() => JsonMapper.Map(result));
}, ct);
}
public ContentData Transform(DataScriptVars vars)
{
Guard.NotNull(vars);
return Run(vars, (vars, _) =>
{
ContentData? result = null;
engine.SetValue("complete", new Action<JsValue?>(_ =>
{
result ??= vars.Data;
}));
engine.SetValue("replace", new Action(() =>
{
if (result == null && TransformData(out var modified))
{
result = modified;
}
}));
engine.Evaluate(parsed);
return result ?? vars.Data!;
});
}
public ValueTask<ContentData> TransformAsync(DataScriptVars vars,
CancellationToken ct = default)
{
Guard.NotNull(vars);
return RunAsync<ContentData, DataScriptVars>(vars, async (vars, context, ct) =>
{
var data = vars.Data!;
engine.SetValue("complete", new Action<JsValue?>(_ =>
{
if (!context.IsCompleted)
{
context.Complete(data);
}
}));
engine.SetValue("replace", new Action(() =>
{
if (!context.IsCompleted && TransformData(out var modified))
{
context.Complete(modified);
}
}));
// The evaluation holds the lock, but returns without waiting for the pending promises.
await RunLockedAsync(() => engine.Evaluate(parsed), ct);
return await context.WaitForCompletionAsync(() => data);
}, ct);
}
private T Run<T, TVars>(TVars vars, Func<TVars, ScriptExecutionContext, T> action) where TVars : ScriptVars
{
executionGate.Wait();
try
{
// The extensions resolve the variables over the context, therefore it is also needed here.
var context = new ScriptExecutionContext(engine);
context.CopyFrom(vars);
PrepareEngine(vars, default);
return action(vars, context);
}
catch (Exception ex)
{
throw MapException(ex);
}
finally
{
executionGate.Release();
}
}
private async ValueTask<T> RunAsync<T, TVars>(TVars vars, Func<TVars, AsyncScriptExecutionContext<T>, CancellationToken, ValueTask<T>> action,
CancellationToken ct) where TVars : ScriptVars
{
using var combined = CancellationTokenSource.CreateLinkedTokenSource(ct);
// Enforce a timeout after a configured time span.
combined.CancelAfter(engineOptions.TimeoutExecution);
await executionGate.WaitAsync(ct);
try
{
PrepareEngine(vars, combined.Token);
// The extensions resolve the variables over the context, therefore it is also needed here.
var context = new AsyncScriptExecutionContext<T>(engine, this, combined.Token);
context.CopyFrom(vars);
return await action(vars, context, combined.Token);
}
catch (Exception ex)
{
throw MapException(ex);
}
finally
{
// Stop pending tasks before the token source is disposed, they must not touch the engine anymore.
await combined.CancelAsync();
// The next execution reuses the engine, therefore we wait until no callback is inside anymore.
// Callbacks that are still waiting for the lock are rejected, because the token is cancelled by now.
await engineLock.WaitAsync(default(CancellationToken));
engineLock.Release();
executionGate.Release();
}
}
public void Dispose()
{
engine.Dispose();
engineLock.Dispose();
executionGate.Dispose();
placeholder.Dispose();
}
internal async Task<T> RunLockedAsync<T>(Func<T> action, CancellationToken ct)
{
// Only one thread is allowed inside the engine, no matter whether it is an execution or a callback.
// A callback that is reached synchronously from the engine thread asks for the lock while the
// evaluation still holds it. That is not a deadlock, because the wait yields instead of blocking and
// the callback continues on another thread once the evaluation is done. It must not be made
// reentrant, that would run the promise jobs in the middle of a statement.
await engineLock.WaitAsync(ct);
try
{
return action();
}
finally
{
engineLock.Release();
}
}
private bool TransformData(out ContentData result)
{
var dataInstance =
scriptOptions.AsContext ?
engine.GetValue("ctx").AsObject().Get("data") :
engine.GetValue("data");
if (dataInstance != null &&
dataInstance.IsObject() &&
dataInstance.AsObject() is ContentDataObject dataObject &&
dataObject.TryUpdate(out var modified))
{
result = modified;
return true;
}
result = null!;
return false;
}
private void PrepareEngine(ScriptVars vars, CancellationToken ct)
{
// Removes everything the previous execution has declared, including global const and let.
engine.Advanced.RestoreGlobalSnapshot(snapshot);
engine.Constraints.Reset();
// The engine is created once, therefore the token of the current execution is assigned here.
// The generic reset above does not clear it, so the callbacks keep the token as well.
cancellation?.Reset(ct);
if (scriptOptions.AsContext)
{
var contextInstance = new WritableContext(engine, vars);
engine.SetValue("ctx", contextInstance);
engine.SetValue("context", contextInstance);
}
else
{
foreach (var (key, item) in vars)
{
engine.SetValue(key, item);
}
}
}
internal static Exception MapException(Exception inner)
{
static Exception BuildException(string errorKey, string message, Exception? inner = null)
{
return new ValidationException(T.Get(errorKey, new { message }), inner);
}
switch (inner)
{
case ArgumentException:
return BuildException("common.jsParseError", inner.Message);
case ParseErrorException:
return BuildException("common.jsError", inner.Message);
case ScriptPreparationException:
return BuildException("common.jsError", inner.Message);
case JavaScriptException:
return BuildException("common.jsError", inner.Message);
case JintException:
return BuildException("common.jsError", inner.Message);
case DomainException:
return inner;
default:
return BuildException("common.jsError", inner.GetType().Name, inner);
}
}
}

193
backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/JintScriptEngine.cs

@ -5,21 +5,11 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Diagnostics;
using Acornima;
using Jint;
using Jint.Native;
using Jint.Runtime;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Core.Properties;
using Squidex.Domain.Apps.Core.Scripting.ContentWrapper;
using Squidex.Domain.Apps.Core.Scripting.Internal;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Json.Objects;
using Squidex.Infrastructure.Translations;
using Squidex.Infrastructure.Validation;
namespace Squidex.Domain.Apps.Core.Scripting;
@ -27,196 +17,29 @@ public sealed class JintScriptEngine(IMemoryCache cache, IOptions<JintScriptOpti
{
private readonly IJintExtension[] extensions = extensions?.ToArray() ?? [];
private readonly CacheParser parser = new CacheParser(cache);
private readonly TimeSpan timeoutScript = options.Value.TimeoutScript;
private readonly TimeSpan timeoutExecution = options.Value.TimeoutExecution;
private readonly TimeSpan timeoutPromise = options.Value.TimeoutPromise;
public async Task<JsonValue> ExecuteAsync(ScriptVars vars, string script, ScriptOptions options = default,
CancellationToken ct = default)
public IScript CreateScript(string script, ScriptOptions scriptOptions = default)
{
Guard.NotNull(vars);
Guard.NotNullOrEmpty(script);
using var combined = CancellationTokenSource.CreateLinkedTokenSource(ct);
try
{
// Enforce a timeout after a configured time span.
combined.CancelAfter(timeoutExecution);
var context =
CreateEngine<JsonValue>(options, combined.Token)
.ExtendWithVariables(vars, options)
.ExtendWithFunctions(extensions)
.ExtendWithAsyncFunctions(extensions);
context.Engine.SetValue("complete", new Action<JsValue?>(value =>
{
context.Complete(JsonMapper.Map(value));
}));
var result = await ExecuteAsync(context, script);
return await context.WaitForCompletionAsync(() => JsonMapper.Map(result));
}
catch (Exception ex)
{
throw MapException(ex);
}
finally
{
// Stop pending tasks before the token source is disposed, they must not touch the engine anymore.
await combined.CancelAsync();
}
return CreateScriptCore(script, scriptOptions, false);
}
public async Task<ContentData> TransformAsync(DataScriptVars vars, string script, ScriptOptions options = default,
CancellationToken ct = default)
public IAsyncScript CreateAsyncScript(string script, ScriptOptions scriptOptions = default)
{
Guard.NotNull(vars);
Guard.NotNullOrEmpty(script);
var data = vars.Data!;
using var combined = CancellationTokenSource.CreateLinkedTokenSource(ct);
try
{
// Enforce a timeout after a configured time span.
combined.CancelAfter(timeoutExecution);
var context =
CreateEngine<ContentData>(options, combined.Token)
.ExtendWithVariables(vars, options)
.ExtendWithFunctions(extensions)
.ExtendWithAsyncFunctions(extensions);
context.Engine.SetValue("complete", new Action<JsValue?>(_ =>
{
context.Complete(data!);
}));
context.Engine.SetValue("replace", new Action(() =>
{
var dataInstance = context.Engine.GetValue("ctx").AsObject().Get("data");
if (dataInstance != null && dataInstance.IsObject() && dataInstance.AsObject() is ContentDataObject data)
{
if (!context.IsCompleted && data.TryUpdate(out var modified))
{
context.Complete(modified);
}
}
}));
await ExecuteAsync(context, script);
return await context.WaitForCompletionAsync(() => data);
}
catch (Exception ex)
{
throw MapException(ex);
}
finally
{
// Stop pending tasks before the token source is disposed, they must not touch the engine anymore.
await combined.CancelAsync();
}
return CreateScriptCore(script, scriptOptions, true);
}
public JsonValue Execute(ScriptVars vars, string script, ScriptOptions options = default)
private JintScript CreateScriptCore(string script, ScriptOptions scriptOptions, bool allowAsync)
{
Guard.NotNull(vars);
Guard.NotNullOrEmpty(script);
try
{
var context =
CreateEngine<object>(options, default)
.ExtendWithVariables(vars, options)
.ExtendWithFunctions(extensions);
var result = Execute(context, script);
return JsonMapper.Map(result);
// The parser caches the prepared script, therefore the same source is only parsed once.
return new JintScript(parser.Parse(script), scriptOptions, options.Value, extensions, allowAsync);
}
catch (Exception ex)
{
throw MapException(ex);
}
}
private ScriptExecutionContext<T> CreateEngine<T>(ScriptOptions options, CancellationToken ct)
{
if (Debugger.IsAttached)
{
ct = default;
}
var engine = new Engine(engineOptions =>
{
engineOptions.AddObjectConverter(JintObjectConverter.Instance);
engineOptions.AllowClrWrite(!options.Readonly);
engineOptions.SetTypeConverter(engine => new CustomClrConverter(engine));
engineOptions.SetReferencesResolver(NullPropagation.Instance);
engineOptions.Strict();
if (!Debugger.IsAttached)
{
engineOptions.Constraints.PromiseTimeout = timeoutPromise;
engineOptions.TimeoutInterval(timeoutScript);
engineOptions.CancellationToken(ct);
}
});
if (options.CanDisallow)
{
engine.AddDisallow();
}
if (options.CanReject)
{
engine.AddReject();
}
return new ScriptExecutionContext<T>(engine, ct);
}
private JsValue Execute(ScriptExecutionContext context, string script)
{
var parsed = parser.Parse(script);
return context.Evaluate(parsed);
}
private Task<JsValue> ExecuteAsync(ScriptExecutionContext context, string script)
{
var parsed = parser.Parse(script);
return context.EvaluateAsync(parsed);
}
private static Exception MapException(Exception inner)
{
static Exception BuildException(string errorKey, string message, Exception? inner = null)
{
return new ValidationException(T.Get(errorKey, new { message }), inner);
}
switch (inner)
{
case ArgumentException:
return BuildException("common.jsParseError", inner.Message);
case ParseErrorException:
return BuildException("common.jsError", inner.Message);
case ScriptPreparationException:
return BuildException("common.jsError", inner.Message);
case JavaScriptException:
return BuildException("common.jsError", inner.Message);
case JintException:
return BuildException("common.jsError", inner.Message);
case DomainException:
return inner;
default:
return BuildException("common.jsError", inner.GetType().Name, inner);
throw JintScript.MapException(ex);
}
}

182
backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ScriptExecutionContext.cs

@ -6,24 +6,17 @@
// ==========================================================================
using System.Runtime.CompilerServices;
using Acornima.Ast;
using Jint;
using Jint.Native;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Tasks;
namespace Squidex.Domain.Apps.Core.Scripting;
public abstract class ScriptExecutionContext : ScriptVars
public class ScriptExecutionContext : ScriptVars
{
private static readonly ConditionalWeakTable<Engine, ScriptExecutionContext> Contexts = new ConditionalWeakTable<Engine, ScriptExecutionContext>();
private static readonly ConditionalWeakTable<Engine, ScriptExecutionContext> Contexts = [];
public Engine Engine { get; }
protected ScriptExecutionContext(Engine engine)
internal ScriptExecutionContext(Engine engine)
{
Engine = engine;
// The extensions only get the engine and resolve the context from there.
Contexts.AddOrUpdate(engine, this);
}
@ -39,175 +32,18 @@ public abstract class ScriptExecutionContext : ScriptVars
return context;
}
public abstract JsValue Evaluate(Prepared<Script> script);
public abstract Task<JsValue> EvaluateAsync(Prepared<Script> script);
public abstract void Schedule(Func<CancellationToken, Task> action);
public abstract void Schedule<TResult>(Func<CancellationToken, Task<TResult>> action, Action<TResult>? callback);
}
public sealed class ScriptExecutionContext<T> : ScriptExecutionContext
{
private readonly TaskCompletionSource<CompletedValue?> tcs = new TaskCompletionSource<CompletedValue?>(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly SemaphoreSlim engineLock = new SemaphoreSlim(1);
private readonly CancellationTokenRegistration cancellationRegistration;
private readonly CancellationToken cancellationToken;
private int pendingTasks = 1;
private sealed class CompletedValue
{
public T Value { get; init; }
}
private readonly struct Releaser(SemaphoreSlim semaphore) : IDisposable
{
public void Dispose()
{
semaphore.Release();
}
}
public bool IsCompleted
{
get => tcs.Task.IsCompleted;
}
internal ScriptExecutionContext(Engine engine, CancellationToken cancellationToken)
: base(engine)
{
this.cancellationToken = cancellationToken;
// Settle the source on cancellation, so that pending callbacks do not enter the engine anymore.
cancellationRegistration = cancellationToken.Register(static state =>
{
var self = (ScriptExecutionContext<T>)state!;
self.tcs.TrySetCanceled(self.cancellationToken);
},
this);
}
public async Task<T> WaitForCompletionAsync(Func<T> fallback)
{
TryComplete();
try
{
var result = await tcs.Task;
if (result != null)
{
return result.Value;
}
// The fallback converts javascript values and therefore needs exclusive access to the engine.
using (await LockEngineAsync())
{
return fallback();
}
}
finally
{
await cancellationRegistration.DisposeAsync();
}
}
public void Complete(T value)
public virtual void Fail(Exception exception)
{
tcs.TrySetResult(new CompletedValue { Value = value });
// The synchronous path reports the error over the exception of the evaluation itself.
}
public override JsValue Evaluate(Prepared<Script> script)
public virtual void Schedule(Func<CancellationToken, Task> action)
{
// The synchronous path cannot schedule tasks, therefore nothing else can enter the engine.
return Engine.Evaluate(script);
ThrowHelper.NotSupportedException("Async operations are not allowed for this script.");
}
public override Task<JsValue> EvaluateAsync(Prepared<Script> script)
public virtual void Schedule<TResult>(Func<CancellationToken, Task<TResult>> action, Action<TResult>? callback)
{
// The lock cannot be taken here, otherwise we would deadlock.
return Engine.EvaluateAsync(script, cancellationToken);
}
public override void Schedule(Func<CancellationToken, Task> action)
{
ScheduleCoreAsync(CallWithDummyResult(action), null);
}
public override void Schedule<TResult>(Func<CancellationToken, Task<TResult>> action, Action<TResult>? callback)
{
ScheduleCoreAsync(action, callback);
}
private static Func<CancellationToken, Task<bool>> CallWithDummyResult(Func<CancellationToken, Task> action)
{
return async ct =>
{
await action(ct);
return true;
};
}
private void ScheduleCoreAsync<TResult>(Func<CancellationToken, Task<TResult>> action, Action<TResult>? callback)
{
if (IsCompleted)
{
return;
}
async Task ScheduleAsync()
{
TryStart();
try
{
// The action must not touch the engine, so that parallel tasks do not block each other.
var result = await action(cancellationToken);
// The callback converts javascript values and is therefore the only part that needs the lock.
using (await LockEngineAsync())
{
if (!IsCompleted)
{
// The task can take a while, therefore the callback gets a fresh timeout.
Engine.Constraints.Reset();
callback?.Invoke(result);
}
}
TryComplete();
}
catch (Exception ex)
{
TryFail(ex);
}
}
ScheduleAsync().Forget();
}
private async Task<Releaser> LockEngineAsync()
{
await engineLock.WaitAsync(cancellationToken);
return new Releaser(engineLock);
}
private void TryFail(Exception exception)
{
tcs.TrySetException(exception);
}
private void TryStart()
{
Interlocked.Increment(ref pendingTasks);
}
private void TryComplete(CompletedValue? result = null)
{
if (Interlocked.Decrement(ref pendingTasks) <= 0)
{
tcs.TrySetResult(result);
}
ThrowHelper.NotSupportedException("Async operations are not allowed for this script.");
}
}

2
backend/src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj

@ -20,7 +20,7 @@
<ItemGroup>
<PackageReference Include="Fluid.Core" Version="2.31.0" />
<PackageReference Include="GeoJSON.Net" Version="1.4.1" />
<PackageReference Include="Jint" Version="4.8.0" />
<PackageReference Include="Jint" Version="4.16.1" />
<PackageReference Include="Meziantou.Analyzer" Version="3.0.50">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>

44
backend/src/Squidex.Domain.Apps.Entities/Assets/AssetsJintExtension.cs

@ -42,13 +42,13 @@ public sealed class AssetsJintExtension(IServiceProvider serviceProvider) : IJin
private void AddUpdateAsset(Engine engine)
{
if (!engine.GetContext().TryGetValueIfExists<ClaimsPrincipal>("user", out var user))
{
return;
}
var updateAsset = new UpdateAssetDelegate((asset, metadata) =>
{
if (!engine.TryGetVar<ClaimsPrincipal>("user", out var user))
{
throw new JavaScriptException("'updateAsset' is not available in this script.");
}
UpdateAsset(engine, user, asset, metadata);
});
@ -90,25 +90,25 @@ public sealed class AssetsJintExtension(IServiceProvider serviceProvider) : IJin
private void AddGetAssetObject(Engine engine)
{
var context = engine.GetContext();
if (!context.TryGetValueIfExists<DomainId>("appId", out var appId))
{
return;
}
if (!context.TryGetValueIfExists<ClaimsPrincipal>("user", out var user))
{
return;
}
var getAssets = new GetAssetsDelegate((references, callback) =>
{
if (!engine.TryGetVar<DomainId>("appId", out var appId) ||
!engine.TryGetVar<ClaimsPrincipal>("user", out var user))
{
throw new JavaScriptException("'getAssets' is not available in this script.");
}
GetAssets(engine, appId, user, references, callback);
});
var getAsset = new GetAssetsDelegate((references, callback) =>
{
if (!engine.TryGetVar<DomainId>("appId", out var appId) ||
!engine.TryGetVar<ClaimsPrincipal>("user", out var user))
{
throw new JavaScriptException("'getAssetV2' is not available in this script.");
}
GetAsset(engine, appId, user, references, callback);
});
@ -282,16 +282,14 @@ public sealed class AssetsJintExtension(IServiceProvider serviceProvider) : IJin
return true;
case AssetEntityScriptVars vars:
var context = engine.GetContext();
if (!context.TryGetValueIfExists<string>(nameof(AssetScriptVars.AppName), out var appName) ||
!context.TryGetValueIfExists<DomainId>(nameof(AssetScriptVars.AppId), out var appId) ||
!context.TryGetValueIfExists<DomainId>(nameof(AssetScriptVars.AssetId), out var assetId))
if (!engine.TryGetVar<string>(nameof(AssetScriptVars.AppName), out var appName) ||
!engine.TryGetVar<DomainId>(nameof(AssetScriptVars.AppId), out var appId) ||
!engine.TryGetVar<DomainId>(nameof(AssetScriptVars.AssetId), out var assetId))
{
return false;
}
context.TryGetValueIfExists<string?>(nameof(AssetScriptVars.FileId), out var fileId);
engine.TryGetVar<string?>(nameof(AssetScriptVars.FileId), out var fileId);
assetRef = new AssetRef(
NamedId.Of(appId, appName),

35
backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/Steps/ScriptAsset.cs

@ -13,6 +13,18 @@ namespace Squidex.Domain.Apps.Entities.Assets.Queries.Steps;
public sealed class ScriptAsset(IScriptEngine scriptEngine) : IAssetEnricherStep
{
private static readonly ScriptOptions PreOptions = new ScriptOptions
{
AsContext = true,
};
private static readonly ScriptOptions Options = new ScriptOptions
{
AsContext = true,
CanDisallow = true,
CanReject = true,
};
public async Task EnrichAsync(Context context, IEnumerable<EnrichedAsset> assets,
CancellationToken ct)
{
@ -40,21 +52,19 @@ public sealed class ScriptAsset(IScriptEngine scriptEngine) : IAssetEnricherStep
if (!string.IsNullOrWhiteSpace(preScript))
{
var options = new ScriptOptions
{
AsContext = true,
};
await scriptEngine.ExecuteAsync(vars, preScript, options, ct);
await scriptEngine.ExecuteAsync(vars, preScript, PreOptions, ct);
}
// The script is compiled once and then reused for all assets.
using var compiled = scriptEngine.CreateAsyncScript(script, Options);
foreach (var asset in assets)
{
await ScriptAsync(vars, script, asset, ct);
await ScriptAsync(compiled, vars, asset, ct);
}
}
private async Task ScriptAsync(AssetScriptVars sharedVars, string script, EnrichedAsset asset,
private static async Task ScriptAsync(IAsyncScript script, AssetScriptVars sharedVars, EnrichedAsset asset,
CancellationToken ct)
{
// Script vars are just wrappers over dictionaries for better performance.
@ -80,14 +90,7 @@ public sealed class ScriptAsset(IScriptEngine scriptEngine) : IAssetEnricherStep
vars.CopyFrom(sharedVars);
var options = new ScriptOptions
{
AsContext = true,
CanDisallow = true,
CanReject = true,
};
await scriptEngine.ExecuteAsync(vars, script, options, ct);
await script.ExecuteAsync(vars, ct);
}
private static bool ShouldEnrich(Context context)

18
backend/src/Squidex.Domain.Apps.Entities/Contents/ContentsJintExtension.cs

@ -24,20 +24,14 @@ public sealed class ContentsJintExtension(IServiceProvider serviceProvider) : IJ
public void ExtendAsync(Engine engine)
{
var context = engine.GetContext();
if (!context.TryGetValueIfExists<DomainId>("appId", out var appId))
{
return;
}
if (!context.TryGetValueIfExists<ClaimsPrincipal>("user", out var user))
{
return;
}
var getContents = new GetContentsDelegate((schemas, query, callback) =>
{
if (!engine.TryGetVar<DomainId>("appId", out var appId) ||
!engine.TryGetVar<ClaimsPrincipal>("user", out var user))
{
throw new JavaScriptException("'getContents' is not available in this script.");
}
GetContents(engine, appId, user, schemas, query, callback);
});

31
backend/src/Squidex.Domain.Apps.Entities/Contents/Counter/CounterJintExtension.cs

@ -7,6 +7,7 @@
using Jint;
using Jint.Native;
using Jint.Runtime;
using Squidex.Domain.Apps.Core.Scripting;
using Squidex.Domain.Apps.Entities.Properties;
using Squidex.Infrastructure;
@ -21,13 +22,13 @@ public sealed class CounterJintExtension(ICounterService counterService) : IJint
public void Extend(Engine engine)
{
if (!engine.GetContext().TryGetValueIfExists<DomainId>("appId", out var appId))
{
return;
}
var increment = new Func<string, long>(name =>
{
if (!engine.TryGetVar<DomainId>("appId", out var appId))
{
return 0;
}
return Increment(appId, name);
});
@ -35,6 +36,11 @@ public sealed class CounterJintExtension(ICounterService counterService) : IJint
var reset = new CounterResetDelegate((name, value) =>
{
if (!engine.TryGetVar<DomainId>("appId", out var appId))
{
return 0;
}
return Reset(appId, name, value);
});
@ -43,13 +49,13 @@ public sealed class CounterJintExtension(ICounterService counterService) : IJint
public void ExtendAsync(Engine engine)
{
if (!engine.GetContext().TryGetValueIfExists<DomainId>("appId", out var appId))
{
return;
}
var increment = new Action<string, Action<JsValue>>((name, callback) =>
{
if (!engine.TryGetVar<DomainId>("appId", out var appId))
{
throw new JavaScriptException("'incrementCounterV2' is not available in this script.");
}
IncrementV2(engine, appId, name, callback);
});
@ -57,6 +63,11 @@ public sealed class CounterJintExtension(ICounterService counterService) : IJint
var reset = new CounterResetV2Delegate((name, callback, value) =>
{
if (!engine.TryGetVar<DomainId>("appId", out var appId))
{
throw new JavaScriptException("'resetCounterV2' is not available in this script.");
}
ResetV2(engine, appId, name, callback, value);
});

35
backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/ScriptContent.cs

@ -12,6 +12,18 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries.Steps;
public sealed class ScriptContent(IScriptEngine scriptEngine) : IContentEnricherStep
{
private static readonly ScriptOptions PreOptions = new ScriptOptions
{
AsContext = true,
};
private static readonly ScriptOptions Options = new ScriptOptions
{
AsContext = true,
CanDisallow = true,
CanReject = true,
};
public async Task EnrichAsync(Context context, IEnumerable<EnrichedContent> contents, ProvideSchema schemas,
CancellationToken ct)
{
@ -46,22 +58,20 @@ public sealed class ScriptContent(IScriptEngine scriptEngine) : IContentEnricher
if (!string.IsNullOrWhiteSpace(preScript))
{
var options = new ScriptOptions
{
AsContext = true,
};
await scriptEngine.ExecuteAsync(vars, preScript, options, ct);
await scriptEngine.ExecuteAsync(vars, preScript, PreOptions, ct);
}
// The script is compiled once and then reused for all contents of the schema.
using var compiled = scriptEngine.CreateAsyncScript(script, Options);
foreach (var content in group)
{
await TransformAsync(vars, script, content, ct);
await TransformAsync(compiled, vars, content, ct);
}
}
}
private async Task TransformAsync(ContentScriptVars sharedVars, string script, EnrichedContent content,
private static async Task TransformAsync(IAsyncScript script, ContentScriptVars sharedVars, EnrichedContent content,
CancellationToken ct)
{
// Script vars are just wrappers over dictionaries for better performance.
@ -80,14 +90,7 @@ public sealed class ScriptContent(IScriptEngine scriptEngine) : IContentEnricher
vars.CopyFrom(sharedVars);
var options = new ScriptOptions
{
AsContext = true,
CanDisallow = true,
CanReject = true,
};
content.Data = await scriptEngine.TransformAsync(vars, script, options, ct);
content.Data = await script.TransformAsync(vars, ct);
}
private static bool ShouldEnrich(Context context)

24
backend/src/Squidex.Domain.Apps.Entities/Contents/ReferencesJintExtension.cs

@ -24,25 +24,25 @@ public sealed class ReferencesJintExtension(IServiceProvider serviceProvider) :
public void ExtendAsync(Engine engine)
{
var context = engine.GetContext();
if (!context.TryGetValueIfExists<DomainId>("appId", out var appId))
{
return;
}
if (!context.TryGetValueIfExists<ClaimsPrincipal>("user", out var user))
{
return;
}
var getReference = new GetReferencesDelegate((references, callback) =>
{
if (!engine.TryGetVar<DomainId>("appId", out var appId) ||
!engine.TryGetVar<ClaimsPrincipal>("user", out var user))
{
throw new JavaScriptException("'getReference' is not available in this script.");
}
GetReference(engine, appId, user, references, callback);
});
var getReferences = new GetReferencesDelegate((references, callback) =>
{
if (!engine.TryGetVar<DomainId>("appId", out var appId) ||
!engine.TryGetVar<ClaimsPrincipal>("user", out var user))
{
throw new JavaScriptException("'getReferences' is not available in this script.");
}
GetReferences(engine, appId, user, references, callback);
});

2
backend/src/Squidex.Domain.Apps.Entities/Rules/RuleEnqueuer.cs

@ -133,7 +133,7 @@ public sealed class RuleEnqueuer(
return appProvider.GetRulesAsync(appId);
}
var cacheKey = (typeof(RuleEnqueuer), appId);
var cacheKey = $"{typeof(RuleEnqueuer)}_Rules_{appId}";
// Cache the rules for performance reasons for a short period of time (usually 10 sec).
return cache.GetOrCreateAsync(cacheKey, entry =>

24
backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/JintScriptEngineHelperTests.cs

@ -25,7 +25,7 @@ public class JintScriptEngineHelperTests : IClassFixture<TranslationsFixture>
private readonly IHttpClientFactory httpClientFactory = A.Fake<IHttpClientFactory>();
private readonly ITranslator translator = A.Fake<ITranslator>();
private readonly IChatAgent chatAgent = A.Fake<IChatAgent>();
private readonly JintScriptEngine sut;
private readonly IScriptEngine sut;
public JintScriptEngineHelperTests()
{
@ -266,7 +266,7 @@ public class JintScriptEngineHelperTests : IClassFixture<TranslationsFixture>
reject()
";
var ex = await Assert.ThrowsAsync<ValidationException>(() => sut.ExecuteAsync(vars, script, options));
var ex = await Assert.ThrowsAsync<ValidationException>(() => sut.ExecuteAsync(vars, script, options).AsTask());
Assert.NotEmpty(ex.Errors);
}
@ -287,7 +287,7 @@ public class JintScriptEngineHelperTests : IClassFixture<TranslationsFixture>
reject('Error1')
";
var ex = await Assert.ThrowsAsync<ValidationException>(() => sut.ExecuteAsync(vars, script, options));
var ex = await Assert.ThrowsAsync<ValidationException>(() => sut.ExecuteAsync(vars, script, options).AsTask());
Assert.Equal(new[] { "Error1" }, ex.Errors.Select(x => x.Message).ToArray());
}
@ -308,7 +308,7 @@ public class JintScriptEngineHelperTests : IClassFixture<TranslationsFixture>
reject(['Error1', 'Error2'])
";
var ex = await Assert.ThrowsAsync<ValidationException>(() => sut.ExecuteAsync(vars, script, options));
var ex = await Assert.ThrowsAsync<ValidationException>(() => sut.ExecuteAsync(vars, script, options).AsTask());
Assert.Equal(new[] { "Error1", "Error2" }, ex.Errors.Select(x => x.Message).ToArray());
}
@ -329,7 +329,7 @@ public class JintScriptEngineHelperTests : IClassFixture<TranslationsFixture>
disallow()
";
var ex = await Assert.ThrowsAsync<DomainForbiddenException>(() => sut.ExecuteAsync(vars, script, options));
var ex = await Assert.ThrowsAsync<DomainForbiddenException>(() => sut.ExecuteAsync(vars, script, options).AsTask());
Assert.Equal("Script has forbidden the operation.", ex.Message);
}
@ -350,7 +350,7 @@ public class JintScriptEngineHelperTests : IClassFixture<TranslationsFixture>
{
};
var ex = await Assert.ThrowsAsync<DomainForbiddenException>(() => sut.ExecuteAsync(vars, script, options));
var ex = await Assert.ThrowsAsync<DomainForbiddenException>(() => sut.ExecuteAsync(vars, script, options).AsTask());
Assert.Equal("Operation not allowed", ex.Message);
}
@ -368,7 +368,7 @@ public class JintScriptEngineHelperTests : IClassFixture<TranslationsFixture>
});
";
await Assert.ThrowsAsync<ValidationException>(() => sut.ExecuteAsync(vars, script));
await Assert.ThrowsAsync<ValidationException>(() => sut.ExecuteAsync(vars, script).AsTask());
}
[Fact]
@ -386,7 +386,7 @@ public class JintScriptEngineHelperTests : IClassFixture<TranslationsFixture>
});
";
await Assert.ThrowsAsync<ValidationException>(() => sut.ExecuteAsync(vars, script));
await Assert.ThrowsAsync<ValidationException>(() => sut.ExecuteAsync(vars, script).AsTask());
}
[Fact]
@ -402,7 +402,7 @@ public class JintScriptEngineHelperTests : IClassFixture<TranslationsFixture>
getJSON(url, null);
";
await Assert.ThrowsAsync<ValidationException>(() => sut.ExecuteAsync(vars, script));
await Assert.ThrowsAsync<ValidationException>(() => sut.ExecuteAsync(vars, script).AsTask());
}
[Fact]
@ -719,7 +719,7 @@ public class JintScriptEngineHelperTests : IClassFixture<TranslationsFixture>
generate('prompt', null);
";
await Assert.ThrowsAsync<ValidationException>(() => sut.ExecuteAsync(vars, script));
await Assert.ThrowsAsync<ValidationException>(() => sut.ExecuteAsync(vars, script).AsTask());
}
[Fact]
@ -802,7 +802,7 @@ public class JintScriptEngineHelperTests : IClassFixture<TranslationsFixture>
translate('text', 'en', null);
";
await Assert.ThrowsAsync<ValidationException>(() => sut.ExecuteAsync(vars, script));
await Assert.ThrowsAsync<ValidationException>(() => sut.ExecuteAsync(vars, script).AsTask());
}
[Theory]
@ -976,7 +976,7 @@ public class JintScriptEngineHelperTests : IClassFixture<TranslationsFixture>
}});
";
await Assert.ThrowsAsync<ValidationException>(() => sut.ExecuteAsync(vars, script));
await Assert.ThrowsAsync<ValidationException>(() => sut.ExecuteAsync(vars, script).AsTask());
}
private MockupHttpHandler SetupRequest(HttpStatusCode statusCode = HttpStatusCode.OK, StringContent? responseContent = null)

221
backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/JintScriptEngineTests.cs

@ -32,7 +32,7 @@ public class JintScriptEngineTests : IClassFixture<TranslationsFixture>
};
private readonly IHttpClientFactory httpClientFactory = A.Fake<IHttpClientFactory>();
private readonly JintScriptEngine sut;
private readonly IScriptEngine sut;
public JintScriptEngineTests()
{
@ -94,7 +94,7 @@ public class JintScriptEngineTests : IClassFixture<TranslationsFixture>
invalid(()
";
await Assert.ThrowsAsync<ValidationException>(() => sut.ExecuteAsync([], script));
await Assert.ThrowsAsync<ValidationException>(() => sut.ExecuteAsync([], script).AsTask());
}
[Fact]
@ -104,7 +104,7 @@ public class JintScriptEngineTests : IClassFixture<TranslationsFixture>
throw 'Error';
";
await Assert.ThrowsAsync<ValidationException>(() => sut.ExecuteAsync([], script));
await Assert.ThrowsAsync<ValidationException>(() => sut.ExecuteAsync([], script).AsTask());
}
[Fact]
@ -174,7 +174,7 @@ public class JintScriptEngineTests : IClassFixture<TranslationsFixture>
throw 'Error';
";
await Assert.ThrowsAsync<ValidationException>(() => sut.TransformAsync([], script));
await Assert.ThrowsAsync<ValidationException>(() => sut.TransformAsync([], script).AsTask());
}
[Fact]
@ -189,7 +189,7 @@ public class JintScriptEngineTests : IClassFixture<TranslationsFixture>
invalid(();
";
await Assert.ThrowsAsync<ValidationException>(() => sut.TransformAsync(vars, script, contentOptions));
await Assert.ThrowsAsync<ValidationException>(() => sut.TransformAsync(vars, script, contentOptions).AsTask());
}
[Fact]
@ -427,6 +427,217 @@ public class JintScriptEngineTests : IClassFixture<TranslationsFixture>
Assert.Equal(expected, actual);
}
[Fact]
public async Task Should_not_deadlock_if_callback_completes_synchronously()
{
// The callback runs on the thread of the evaluation, which holds the engine lock at that moment.
const string script = @"
function delay() {
return new Promise((resolve) => {
setTimeout(function () {
resolve(1);
}, 0);
});
}
(async () => {
let total = 0;
for (let i = 0; i < 10; i++) {
total += await delay();
}
complete(total);
})()
";
var actual = await sut.ExecuteAsync([], script);
Assert.Equal(JsonValue.Create(10), actual);
}
[Fact]
public async Task Should_throw_if_promise_is_rejected()
{
const string script = @"
(async () => {
await new Promise((resolve, reject) => {
getJSON('http://mockup.squidex.io', function () {
reject('rejected');
});
});
complete(42);
})()
";
await Assert.ThrowsAsync<ValidationException>(() => sut.ExecuteAsync([], script).AsTask());
}
[Fact]
public async Task Should_not_throw_if_rejected_promise_is_handled()
{
const string script = @"
(async () => {
try {
await new Promise((resolve, reject) => {
getJSON('http://mockup.squidex.io', function () {
reject('rejected');
});
});
} catch (e) {
complete(42);
}
})()
";
var actual = await sut.ExecuteAsync([], script);
Assert.Equal(JsonValue.Create(42), actual);
}
[Fact]
public void Should_not_leak_globals_between_executions()
{
var script = sut.CreateScript("var actual = typeof leaked; var leaked = 1; actual");
for (var i = 1; i <= 3; i++)
{
Assert.Equal(JsonValue.Create("undefined"), script.Execute([]));
}
}
[Fact]
public void Should_leak_prototype_changes_between_executions_of_same_script()
{
// The snapshot restores the globals, but it does not undo changes to the prototypes. That is
// acceptable because a script is never shared between apps, but it must not go unnoticed.
var script = sut.CreateScript("var actual = ({}).polluted; Object.prototype.polluted = 'yes'; typeof actual");
Assert.Equal(JsonValue.Create("undefined"), script.Execute([]));
Assert.Equal(JsonValue.Create("string"), script.Execute([]));
}
[Fact]
public void Should_not_leak_prototype_changes_to_other_scripts()
{
sut.CreateScript("Object.prototype.polluted = 'yes'; 1").Execute([]);
var script = sut.CreateScript("typeof ({}).polluted");
Assert.Equal(JsonValue.Create("undefined"), script.Execute([]));
}
[Fact]
public void Should_complete_sync_script()
{
var script = sut.CreateScript("complete(42); 1");
Assert.Equal(JsonValue.Create(42), script.Execute([]));
}
[Fact]
public void Should_transform_with_sync_script()
{
var vars = new DataScriptVars
{
["data"] = new ContentData(),
};
var script = sut.CreateScript("ctx.data.number = { iv: 42 }; replace()", contentOptions);
var actual = script.Transform(vars);
Assert.Equal(JsonValue.Create(42), actual["number"]!["iv"]);
}
[Fact]
public async Task Should_cancel_async_script()
{
using var cts = new CancellationTokenSource();
var script = sut.CreateAsyncScript("while (true) { }");
await cts.CancelAsync();
await Assert.ThrowsAnyAsync<Exception>(() => script.ExecuteAsync([], cts.Token).AsTask());
}
[Fact]
public async Task Should_run_same_script_in_parallel()
{
var script = sut.CreateAsyncScript("const factor = 2; value.i * factor");
var tasks = Enumerable.Range(1, 20).Select(async i =>
{
var vars = new ScriptVars
{
["value"] = new { i },
};
return (i, actual: await script.ExecuteAsync(vars));
});
foreach (var (i, actual) in await Task.WhenAll(tasks))
{
Assert.Equal(JsonValue.Create(i * 2), actual);
}
}
[Fact]
public async Task Should_reuse_script_with_callbacks()
{
var script = sut.CreateAsyncScript(@"
const factor = value.i;
getJSON('http://mockup.squidex.io', function(actual) {
complete(actual.key * factor);
});
");
for (var i = 1; i <= 3; i++)
{
var vars = new ScriptVars
{
["value"] = new { i },
};
Assert.Equal(JsonValue.Create(42 * i), await script.ExecuteAsync(vars));
}
}
[Fact]
public void Should_reuse_script_with_global_declarations()
{
var script = sut.CreateScript("const factor = 2; value.i * factor");
for (var i = 1; i <= 3; i++)
{
var vars = new ScriptVars
{
["value"] = new { i },
};
Assert.Equal(JsonValue.Create(i * 2), script.Execute(vars));
}
}
[Fact]
public async Task Should_reuse_async_script_with_global_declarations()
{
var script = sut.CreateAsyncScript("const factor = 2; value.i * factor");
for (var i = 1; i <= 3; i++)
{
var vars = new ScriptVars
{
["value"] = new { i },
};
Assert.Equal(JsonValue.Create(i * 2), await script.ExecuteAsync(vars));
}
}
[Fact]
public void Evaluate_should_return_true_if_expression_match()
{

19
backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/MockupHttpHandler.cs

@ -47,6 +47,23 @@ internal sealed class MockupHttpHandler(HttpResponseMessage response) : HttpMess
currentContentType = body.Headers.ContentType?.MediaType;
}
return response;
// The caller disposes the response, therefore every request gets its own copy of the template.
var result = new HttpResponseMessage(response.StatusCode)
{
Content = new StringContent(await response.Content.ReadAsStringAsync(cancellationToken)),
};
foreach (var (key, values) in response.Content.Headers)
{
result.Content.Headers.Remove(key);
result.Content.Headers.TryAddWithoutValidation(key, values);
}
foreach (var (key, values) in response.Headers)
{
result.Headers.TryAddWithoutValidation(key, values);
}
return result;
}
}

8
backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetsJintExtensionTests.cs

@ -31,7 +31,7 @@ public class AssetsJintExtensionTests : GivenContext, IClassFixture<Translations
private readonly IAssetFileStore assetFileStore = A.Fake<IAssetFileStore>();
private readonly IAssetQueryService assetQuery = A.Fake<IAssetQueryService>();
private readonly IAssetThumbnailGenerator assetGenerator = A.Fake<IAssetThumbnailGenerator>();
private readonly JintScriptEngine sut;
private readonly IScriptEngine sut;
public static readonly TheoryData<string> Encodings =
new TheoryData<string>("ascii", "unicode", "utf8", "base64");
@ -83,7 +83,7 @@ public class AssetsJintExtensionTests : GivenContext, IClassFixture<Translations
var script = @"getAsset('id')";
await Assert.ThrowsAsync<ValidationException>(() => sut.ExecuteAsync(vars, script, ct: CancellationToken));
await Assert.ThrowsAsync<ValidationException>(() => sut.ExecuteAsync(vars, script, ct: CancellationToken).AsTask());
}
[Fact]
@ -114,7 +114,7 @@ public class AssetsJintExtensionTests : GivenContext, IClassFixture<Translations
var script = @"getAssetV2('id')";
await Assert.ThrowsAsync<ValidationException>(() => sut.ExecuteAsync(vars, script, ct: CancellationToken));
await Assert.ThrowsAsync<ValidationException>(() => sut.ExecuteAsync(vars, script, ct: CancellationToken).AsTask());
}
[Fact]
@ -145,7 +145,7 @@ public class AssetsJintExtensionTests : GivenContext, IClassFixture<Translations
var script = @"getAssetV2('id')";
await Assert.ThrowsAsync<ValidationException>(() => sut.ExecuteAsync(vars, script, ct: CancellationToken));
await Assert.ThrowsAsync<ValidationException>(() => sut.ExecuteAsync(vars, script, ct: CancellationToken).AsTask());
}
[Fact]

24
backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/Queries/ScriptAssetTests.cs

@ -16,10 +16,14 @@ namespace Squidex.Domain.Apps.Entities.Assets.Queries;
public class ScriptAssetTests : GivenContext
{
private readonly IScriptEngine scriptEngine = A.Fake<IScriptEngine>();
private readonly IAsyncScript script = A.Fake<IAsyncScript>();
private readonly ScriptAsset sut;
public ScriptAssetTests()
{
A.CallTo(() => scriptEngine.CreateAsyncScript(A<string>._, A<ScriptOptions>._))
.Returns(script);
sut = new ScriptAsset(scriptEngine);
}
@ -30,6 +34,9 @@ public class ScriptAssetTests : GivenContext
await sut.EnrichAsync(ApiContext, [asset], CancellationToken);
A.CallTo(() => script.ExecuteAsync(A<AssetScriptVars>._, A<CancellationToken>._))
.MustNotHaveHappened();
A.CallTo(() => scriptEngine.ExecuteAsync(A<AssetScriptVars>._, A<string>._, ScriptOptions(), A<CancellationToken>._))
.MustNotHaveHappened();
}
@ -43,6 +50,9 @@ public class ScriptAssetTests : GivenContext
await sut.EnrichAsync(FrontendContext, [asset], CancellationToken);
A.CallTo(() => script.ExecuteAsync(A<AssetScriptVars>._, A<CancellationToken>._))
.MustNotHaveHappened();
A.CallTo(() => scriptEngine.ExecuteAsync(A<AssetScriptVars>._, A<string>._, ScriptOptions(), A<CancellationToken>._))
.MustNotHaveHappened();
}
@ -56,6 +66,9 @@ public class ScriptAssetTests : GivenContext
await sut.EnrichAsync(ContextWithNoScript(), [asset], CancellationToken);
A.CallTo(() => script.ExecuteAsync(A<AssetScriptVars>._, A<CancellationToken>._))
.MustNotHaveHappened();
A.CallTo(() => scriptEngine.ExecuteAsync(A<AssetScriptVars>._, A<string>._, ScriptOptions(), A<CancellationToken>._))
.MustNotHaveHappened();
}
@ -69,14 +82,15 @@ public class ScriptAssetTests : GivenContext
await sut.EnrichAsync(ApiContext, [asset], CancellationToken);
A.CallTo(() => scriptEngine.ExecuteAsync(
A.CallTo(() => scriptEngine.CreateAsyncScript("my-query", ScriptOptions()))
.MustHaveHappened();
A.CallTo(() => script.ExecuteAsync(
A<AssetScriptVars>.That.Matches(x =>
Equals(x["assetId"], asset.Id) &&
Equals(x["appId"], AppId.Id) &&
Equals(x["appName"], AppId.Name) &&
Equals(x["user"], ApiContext.UserPrincipal)),
"my-query",
ScriptOptions(),
CancellationToken))
.MustHaveHappened();
}
@ -101,14 +115,12 @@ public class ScriptAssetTests : GivenContext
CancellationToken))
.MustHaveHappened();
A.CallTo(() => scriptEngine.ExecuteAsync(
A.CallTo(() => script.ExecuteAsync(
A<AssetScriptVars>.That.Matches(x =>
Equals(x.GetValue<object>("assetId"), asset.Id) &&
Equals(x["appId"], AppId.Id) &&
Equals(x["appName"], AppId.Name) &&
Equals(x["user"], ApiContext.UserPrincipal)),
"my-query",
ScriptOptions(),
CancellationToken))
.MustHaveHappened();
}

4
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentsJintExtensionTests.cs

@ -22,7 +22,7 @@ namespace Squidex.Domain.Apps.Entities.Contents;
public class ContentsJintExtensionTests : GivenContext, IClassFixture<TranslationsFixture>
{
private readonly IContentQueryService contentQuery = A.Fake<IContentQueryService>();
private readonly JintScriptEngine sut;
private readonly IScriptEngine sut;
public ContentsJintExtensionTests()
{
@ -53,7 +53,7 @@ public class ContentsJintExtensionTests : GivenContext, IClassFixture<Translatio
var script = @"getContents('my-schema', '$filter=data/field/iv eq 42')";
await Assert.ThrowsAsync<ValidationException>(() => sut.ExecuteAsync(vars, script, ct: CancellationToken));
await Assert.ThrowsAsync<ValidationException>(() => sut.ExecuteAsync(vars, script, ct: CancellationToken).AsTask());
}
[Fact]

2
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Counter/CounterJintExtensionTests.cs

@ -15,7 +15,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Counter;
public class CounterJintExtensionTests : GivenContext
{
private readonly ICounterService counterService = A.Fake<ICounterService>();
private readonly JintScriptEngine sut;
private readonly IScriptEngine sut;
public CounterJintExtensionTests()
{

2
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/DomainObject/ContentDomainObjectTests.cs

@ -95,7 +95,7 @@ public class ContentDomainObjectTests : HandlerTestBase<WriteContent>
.Publish();
A.CallTo(() => scriptEngine.TransformAsync(A<DataScriptVars>._, A<string>._, ScriptOptions(), CancellationToken))
.ReturnsLazily(x => Task.FromResult(x.GetArgument<DataScriptVars>(0)!.Data!));
.ReturnsLazily(x => new ValueTask<ContentData>(x.GetArgument<DataScriptVars>(0)!.Data!));
A.CallTo(() => scriptEngine.Execute(A<ScriptVars>._, A<string>._, A<ScriptOptions>._))
.Returns(JsonValue.Create(43));

24
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ScriptContentTests.cs

@ -19,10 +19,14 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries;
public class ScriptContentTests : GivenContext
{
private readonly IScriptEngine scriptEngine = A.Fake<IScriptEngine>();
private readonly IAsyncScript script = A.Fake<IAsyncScript>();
private readonly ScriptContent sut;
public ScriptContentTests()
{
A.CallTo(() => scriptEngine.CreateAsyncScript(A<string>._, A<ScriptOptions>._))
.Returns(script);
sut = new ScriptContent(scriptEngine);
}
@ -33,7 +37,7 @@ public class ScriptContentTests : GivenContext
await sut.EnrichAsync(ApiContext, [content], SchemaProvider(), CancellationToken);
A.CallTo(() => scriptEngine.TransformAsync(A<DataScriptVars>._, A<string>._, ScriptOptions(), A<CancellationToken>._))
A.CallTo(() => script.TransformAsync(A<DataScriptVars>._, A<CancellationToken>._))
.MustNotHaveHappened();
}
@ -49,7 +53,7 @@ public class ScriptContentTests : GivenContext
await sut.EnrichAsync(FrontendContext, [content], SchemaProvider(), CancellationToken);
A.CallTo(() => scriptEngine.TransformAsync(A<DataScriptVars>._, A<string>._, ScriptOptions(), A<CancellationToken>._))
A.CallTo(() => script.TransformAsync(A<DataScriptVars>._, A<CancellationToken>._))
.MustNotHaveHappened();
}
@ -65,7 +69,7 @@ public class ScriptContentTests : GivenContext
await sut.EnrichAsync(ContextWithNoScript(), [content], SchemaProvider(), CancellationToken);
A.CallTo(() => scriptEngine.TransformAsync(A<DataScriptVars>._, A<string>._, ScriptOptions(), A<CancellationToken>._))
A.CallTo(() => script.TransformAsync(A<DataScriptVars>._, A<CancellationToken>._))
.MustNotHaveHappened();
}
@ -84,15 +88,16 @@ public class ScriptContentTests : GivenContext
Assert.NotSame(contentBefore.Data, contentData);
A.CallTo(() => scriptEngine.TransformAsync(
A.CallTo(() => scriptEngine.CreateAsyncScript("my-query", ScriptOptions()))
.MustHaveHappened();
A.CallTo(() => script.TransformAsync(
A<DataScriptVars>.That.Matches(x =>
Equals(x["contentId"], contentBefore.Id) &&
Equals(x["data"], contentData) &&
Equals(x["appId"], AppId.Id) &&
Equals(x["appName"], AppId.Name) &&
Equals(x["user"], ApiContext.UserPrincipal)),
"my-query",
ScriptOptions(),
CancellationToken))
.MustHaveHappened();
}
@ -124,15 +129,16 @@ public class ScriptContentTests : GivenContext
CancellationToken))
.MustHaveHappened();
A.CallTo(() => scriptEngine.TransformAsync(
A.CallTo(() => scriptEngine.CreateAsyncScript("my-query", ScriptOptions()))
.MustHaveHappened();
A.CallTo(() => script.TransformAsync(
A<DataScriptVars>.That.Matches(x =>
Equals(x["contentId"], contentBefore.Id) &&
Equals(x["data"], contentData) &&
Equals(x["appId"], AppId.Id) &&
Equals(x["appName"], AppId.Name) &&
Equals(x["user"], ApiContext.UserPrincipal)),
"my-query",
ScriptOptions(),
CancellationToken))
.MustHaveHappened();
}

8
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ReferencesJintExtensionTests.cs

@ -22,7 +22,7 @@ namespace Squidex.Domain.Apps.Entities.Contents;
public class ReferencesJintExtensionTests : GivenContext, IClassFixture<TranslationsFixture>
{
private readonly IContentQueryService contentQuery = A.Fake<IContentQueryService>();
private readonly JintScriptEngine sut;
private readonly IScriptEngine sut;
public ReferencesJintExtensionTests()
{
@ -53,7 +53,7 @@ public class ReferencesJintExtensionTests : GivenContext, IClassFixture<Translat
var script = @"getReference('id')";
await Assert.ThrowsAsync<ValidationException>(() => sut.ExecuteAsync(vars, script, ct: CancellationToken));
await Assert.ThrowsAsync<ValidationException>(() => sut.ExecuteAsync(vars, script, ct: CancellationToken).AsTask());
}
[Fact]
@ -84,7 +84,7 @@ public class ReferencesJintExtensionTests : GivenContext, IClassFixture<Translat
var script = @"getReferenceV2('id')";
await Assert.ThrowsAsync<ValidationException>(() => sut.ExecuteAsync(vars, script, ct: CancellationToken));
await Assert.ThrowsAsync<ValidationException>(() => sut.ExecuteAsync(vars, script, ct: CancellationToken).AsTask());
}
[Fact]
@ -115,7 +115,7 @@ public class ReferencesJintExtensionTests : GivenContext, IClassFixture<Translat
var script = @"getReferences('id')";
await Assert.ThrowsAsync<ValidationException>(() => sut.ExecuteAsync(vars, script, ct: CancellationToken));
await Assert.ThrowsAsync<ValidationException>(() => sut.ExecuteAsync(vars, script, ct: CancellationToken).AsTask());
}
[Fact]

2
backend/tests/Squidex.Web.Tests/Scripting/HttpRequestJintExtensionTests.cs

@ -15,7 +15,7 @@ namespace Squidex.Web.Scripting;
public class HttpRequestJintExtensionTests
{
private readonly IHttpContextAccessor httpContextAccessor = A.Fake<IHttpContextAccessor>();
private readonly JintScriptEngine sut;
private readonly IScriptEngine sut;
public HttpRequestJintExtensionTests()
{

Loading…
Cancel
Save