Browse Source

Upgrade Jint to 4.15.3 and adopt its cache-gate and lazy-value APIs (#1326)

* Pin observable scripting behaviour before the Jint upgrade

The Jint upgrade that follows re-arms three engine-wide inline cache gates.
Each of those changes is only worth making if it is observably identical, so
pin the behaviour first, against Jint 4.8.0:

- null propagation: undeclared identifier reads, nullish property chains,
  calls over a nullish base, calls of a non-callable member (which return
  the base), and that ordinary member reads/calls are unaffected;
- enum values crossing into script as their member name, including a
  [Flags] combination and an enum member of a wrapped CLR object;
- JSON objects projected into script: own key order, JSON.stringify,
  for..in, mutation (add/replace/delete) and the round trip back to
  JsonValue;
- content field objects: `in`, hasOwnProperty, propertyIsEnumerable,
  Object.keys, spread, JSON.stringify, and that all of them follow
  deletes and additions;
- the context object: key enumeration, `in`, typeof per value, reads,
  write-through to ScriptVars and delete.

All 156 tests in Operations/Scripting pass unchanged on 4.8.0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uV6H9cTntzsoKiaJRBn4f

* Upgrade Jint from 4.8.0 to 4.15.3

Package provenance verified after restore: source is
https://api.nuget.org/v3/index.json and the nuspec repository commit is
a304aa5dacd340e2a5ff51e1ea0c465e38e50aa8, the v4.15.3 tag. Acornima moves
to 1.6.2 transitively; ParseErrorException, ScriptPreparationException and
JintException, which JintScriptEngine.MapException switches on, all still
exist, and so do Engine.Constraints.Reset, Options.Constraints.PromiseTimeout,
AllowClrWrite, EvaluateAsync(in Prepared<Script>, CancellationToken),
ObjectWrapper.Create and the ObjectInstance virtuals the ContentWrapper
family overrides.

No source change is needed for the upgrade itself: the whole backend
solution builds warning-clean and all 156 tests in Operations/Scripting,
including the behaviour pins added in the previous commit, pass unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uV6H9cTntzsoKiaJRBn4f

* Verify Jint's host contracts on every test run

The scripting integration defines several Jint extension points: the
ContentWrapper objects override GetOwnProperty, and the engine trusts the
answer without re-verifying it on the hot path. A hook that contradicts
another therefore fails silently in production - a key vanishes from every
enumeration, or a read resolves on the prototype for a property that
exists - which is the class of bug no assertion in this repository would
catch.

Jint 4.15.3 exposes its host-contract verifiers to the shipped Release
package through an AppContext switch, where before they were compiled out
unless you built the engine from source in Debug. A module initializer sets
it for this test assembly, so the verifiers run against the same NuGet
package production uses and report a violation as an ordinary test failure.
It must be set before the first use of any Jint type, which is exactly what
a module initializer guarantees.

Confirmed live rather than assumed: with a deliberately wrong
ProbeOwnProperty the run fails with "ContentFieldObject.ProbeOwnProperty
answered 'iv' with Missing but its GetOwnProperty reports Enumerable".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uV6H9cTntzsoKiaJRBn4f

* Declare the reference resolver's interests to re-arm the read caches

Registering an IReferenceResolver without interests gives it
ReferenceResolverInterests.All, and two of those flags - ObjectPropertyBase
and PrimitivePropertyBase - are the gate on the non-computed member-read
inline caches, the dense-array indexed-read lane and the member-call callee
lane. With All declared, every property read in every script has to be
routed through a Reference so the resolver gets offered the base, and all
three lanes stay off for the whole engine.

NullPropagation.TryPropertyReference returns false for every base that is
not null or undefined, so on those two situations the engine consulting it
can never change the result. Declaring only the three situations the
resolver actually answers - NullishPropertyBase, UnresolvableReference and
NonCallableCallee - is therefore observably identical and re-arms all three
lanes. Interests are documented as a subscription filter and not a promise:
a situation that is not subscribed to behaves exactly as if no resolver
were registered.

Jint also ships a built-in NullPropagatingReferenceResolver, which is
deliberately NOT adopted here: it declines unresolvable identifiers and
non-callable callees, where this resolver answers both, so swapping it in
would turn an undeclared-name read and a call on a nullish chain into
errors for existing tenant scripts.

The behaviour pins from the first commit cover exactly those edges and all
156 tests in Operations/Scripting still pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uV6H9cTntzsoKiaJRBn4f

* Declare the object converter's types and convert enums natively

An IObjectConverter registered without declaring the CLR types it handles
can be handed anything, so the engine has to assume every wrapped CLR
member read might reach it and disables the compiled interop member-read
lane engine-wide. JintObjectConverter handles a closed set, so declare it:
matching is by assignability, which keeps IUser covering every
implementation. The converter is still offered every value - the
declaration only lets the engine keep the fast lane for members whose
declared type could never produce a handled value, and it errs towards
claiming (a member typed `object` is always claimed).

The Enum branch is dropped in favour of
Options.Interop.EnumConversion = EnumConversionMode.String, which Jint
documents as the member name "as produced by object.ToString()", including
the comma-separated combination for a [Flags] value and the numeric value
rendered as a string for a value with no name - verbatim what the branch
did. The write direction keeps accepting both the name and the number.
Handling enums natively rather than through the converter also keeps one
more declared type off the list, so more members stay on the fast lane.

Pinned by Should_convert_enum_to_name, Should_convert_flags_enum_to_names
and Should_convert_enum_member_of_wrapped_object_to_name, which pass before
and after.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uV6H9cTntzsoKiaJRBn4f

* Project JSON objects into script as shape-mode objects

JsonMapper built every JSON object it projects into script as an instance
of a private ObjectInstance subclass that existed only to be instantiable.
A host subclass can never carry the engine's shape-mode storage flag, so
each of those objects - all of ctx.data's leaf objects, and every JsonValue
var - sat permanently outside the own-property inline caches, and a script
reading the same property across a batch of content items re-resolved it
every time.

JsObject.CreateFromEntries builds the same object through the hidden class
machinery instead: repeated calls presenting the same key sequence, which
every content item of one schema does, share an interned hidden class, so
those reads stay monomorphic. The result is documented as indistinguishable
from the equivalent object literal - same own key order, same
configurable/enumerable/writable data properties - and anything the
representation cannot express (a digit-leading key, a very wide object)
falls back to the ordinary dictionary representation rather than to
different behaviour.

That fallback is silent, which is why the shaping is asserted rather than
assumed: Engine.Advanced.HasSharedShape is the supported predicate for it,
and JsonMapperTests pins that the projected object and its nested objects
answer true. Building them as a host subclass again would fail that test.

Three smaller fixes in the same file:

- the reverse direction allocated a string key per array element
  (a.Get(i.ToString(...))); the indexed accessor reads the dense backing
  directly and keeps the prototype walk for a modified array;
- JsNumber.Create reuses cached instances for small integers where
  new JsNumber always allocated;
- JsString.Create, public since 4.15.3, interns the empty and single
  character strings where new JsString always allocated.

Pinned by the projection tests added first - own key order, JSON.stringify,
for..in, mutation including delete and add, and the round trip back to
JsonValue - which pass before and after.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uV6H9cTntzsoKiaJRBn4f

* Answer field existence questions without mapping the field value

ContentFieldProperty is a CustomJsValue descriptor whose CustomValue maps
the stored JsonValue to a JsValue on first read. Existence and
enumerability questions never need that value, but they used to pay for
it: `in`, hasOwnProperty, propertyIsEnumerable, Object.keys/values/entries,
Object.assign, object spread and JSON.stringify all reached the object
through GetOwnProperty, which materializes the descriptor whose value the
caller then reads or discards.

Jint lets a host answer those questions directly through ProbeOwnProperty.
The override deliberately mirrors GetOwnProperty line for line, minus the
descriptor: same initialization, same toJSON exclusion, same lookup, and
the enumerable flag read off the descriptor rather than off its value. The
engine trusts the probe without re-verifying it on the hot path, so a wrong
Missing would silently drop the key from every enumeration above - which is
why the two are kept adjacent in the file, pinned by tests covering `in`,
hasOwnProperty, propertyIsEnumerable, Object.keys, spread and
JSON.stringify plus a delete and an add, and checked on every test run by
the host-contract verification enabled earlier in this branch.

ContentDataObject deliberately does not get the same override: its
GetOwnProperty auto-creates a field for any name probed, so a probe that
agreed with it at the same instant would have to do the same, and that
quirk is pre-existing tenant-visible behaviour this change has no business
altering.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uV6H9cTntzsoKiaJRBn4f

* Map script variables on first read instead of eagerly

Every variable was mapped when the engine was set up, once per evaluation,
whether or not the script ever looked at it - WritableContext did it in its
constructor for the ctx path, and Engine.SetValue did it per variable for
the non-context path. Some of those mappings are not cheap: a user variable
walks and groups every claim, a content data variable builds a wrapper. A
typical script reads a handful of the variables available to it.

Both paths now defer the mapping to the first read of the value, through
the two APIs Jint 4.15.3 added for exactly this:

- PropertyDescriptor.CreateLazy for the ctx object. Unlike a hand-written
  CustomJsValue descriptor it drops the flag once the value exists, so the
  descriptor rejoins the write inline cache instead of paying the
  indirection for the rest of its life.
- Engine.Advanced.AddLazyGlobal for the non-context path. The options-time
  AddLazyGlobal could not serve it - the variables are only known after the
  engine has been built - and the descriptor a host could install itself is
  declined by the global-identifier cache. The Advanced overload is
  documented as being for exactly this case, and its factory may capture
  engine-affine state.

In both cases the property itself is installed eagerly, so nothing about
the shape changes: key order, enumeration, `in`, Object.getOwnPropertyNames,
delete and the write-through to ScriptVars behave exactly as before, which
is what the tests pin - including a counting principal that proves the
mapping has not run for a variable the script never mentions, and has run
for one it reads. MapVariable reproduces Engine.SetValue's special case for
a CLR type so a deferred variable cannot project differently.

One edge is worth recording: Engine.SetValue writes through [[Set]] while
AddLazyGlobal replaces the descriptor, so a variable named after a
non-writable built-in global (undefined, NaN, Infinity) would now shadow it
where it was previously ignored. ScriptVars keys are domain names, so this
is not reachable in practice.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uV6H9cTntzsoKiaJRBn4f

* Rewrite the code comments in plainer language

The comments explained the change in Jint's own vocabulary - inline caches,
shape mode, descriptors, lanes - which is not vocabulary this repository
uses. Say what each change does and why it is worth it instead, and name a
Jint concept only where the reader has to look it up anyway.

No behaviour change: comments and XML docs only, plus one short comment on
the enum conversion option.

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
master
Marko Lahma 3 weeks ago
committed by GitHub
parent
commit
a0f3f5cdb4
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 23
      backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ContentWrapper/ContentFieldObject.cs
  2. 19
      backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Internal/JintExtensions.cs
  3. 29
      backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Internal/JintObjectConverter.cs
  4. 31
      backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Internal/JsonMapper.cs
  5. 8
      backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/JintScriptEngine.cs
  6. 22
      backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/NullPropagation.cs
  7. 11
      backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/WritableContext.cs
  8. 2
      backend/src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj
  9. 132
      backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/ContentDataObjectTests.cs
  10. 493
      backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/JintScriptEngineTests.cs
  11. 52
      backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/JsonMapperTests.cs
  12. 33
      backend/tests/Squidex.Domain.Apps.Core.Tests/TestHelpers/JintHostContractVerification.cs

23
backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ContentWrapper/ContentFieldObject.cs

@ -132,6 +132,29 @@ public sealed class ContentFieldObject : ObjectInstance
return valueProperties?.GetValueOrDefault(propertyName) ?? PropertyDescriptor.Undefined; return valueProperties?.GetValueOrDefault(propertyName) ?? PropertyDescriptor.Undefined;
} }
protected override OwnPropertyProbe ProbeOwnProperty(JsValue property)
{
// Answers whether a key exists without converting its value, which reading the property would do.
// Used by "in", hasOwnProperty, Object.keys, spread and JSON.stringify. Must give the same answer as
// GetOwnProperty above, which Jint does not check at runtime, only in tests (see
// JintHostContractVerification), so the two methods are kept identical apart from the return value.
EnsurePropertiesInitialized();
var propertyName = property.AsString();
if (propertyName.Equals("toJSON", StringComparison.OrdinalIgnoreCase))
{
return OwnPropertyProbe.Missing;
}
if (!valueProperties.TryGetValue(propertyName, out var propertyDescriptor))
{
return OwnPropertyProbe.Missing;
}
return propertyDescriptor.Enumerable ? OwnPropertyProbe.Enumerable : OwnPropertyProbe.NonEnumerable;
}
public override IEnumerable<KeyValuePair<JsValue, PropertyDescriptor>> GetOwnProperties() public override IEnumerable<KeyValuePair<JsValue, PropertyDescriptor>> GetOwnProperties()
{ {
EnsurePropertiesInitialized(); EnsurePropertiesInitialized();

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

@ -7,6 +7,7 @@
using Jint; using Jint;
using Jint.Native; using Jint.Native;
using Jint.Runtime.Interop;
using Squidex.Infrastructure; using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Core.Scripting.Internal; namespace Squidex.Domain.Apps.Core.Scripting.Internal;
@ -73,7 +74,9 @@ public static class JintExtensions
{ {
foreach (var (key, item) in vars) foreach (var (key, item) in vars)
{ {
engine.SetValue(key, item); // Sets the value, but runs the conversion only when the script reads it for the first time.
// The name is added right away, so enumeration and "in" checks work as before.
engine.Advanced.AddLazyGlobal(key, e => MapVariable(e, item));
} }
} }
@ -81,4 +84,18 @@ public static class JintExtensions
return context; return context;
} }
/// <summary>
/// Converts a value exactly like <see cref="Engine.SetValue(string, object)"/> does, including its
/// special case for types, so that a deferred variable cannot look different from an eager one.
/// </summary>
private static JsValue MapVariable(Engine engine, object? item)
{
if (item is Type type)
{
return TypeReference.CreateTypeReference(engine, type);
}
return JsValue.FromObject(engine, item);
}
} }

29
backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Internal/JintObjectConverter.cs

@ -21,6 +21,29 @@ namespace Squidex.Domain.Apps.Core.Scripting.Internal;
public sealed class JintObjectConverter : IObjectConverter public sealed class JintObjectConverter : IObjectConverter
{ {
/// <summary>
/// The types this converter handles, passed to Jint when the converter is registered.
/// </summary>
/// <remarks>
/// Without this list Jint has to offer every property of every .NET object to this converter and cannot
/// use its faster property reader for any of them. Base types and interfaces count, so
/// <see cref="IUser"/> covers all implementations. Keep the list in sync with the switch below - a type
/// that is converted but not listed here fails a test (see JintHostContractVerification). Enums are
/// missing on purpose, they are converted by Jint itself, see EnumConversion in JintScriptEngine.
/// </remarks>
public static readonly Type[] HandledTypes =
[
typeof(IUser),
typeof(ClaimsPrincipal),
typeof(ScriptVars),
typeof(JsonValue),
typeof(DomainId),
typeof(Guid),
typeof(Instant),
typeof(Status),
typeof(ContentData),
];
public static readonly JintObjectConverter Instance = new JintObjectConverter(); public static readonly JintObjectConverter Instance = new JintObjectConverter();
private JintObjectConverter() private JintObjectConverter()
@ -31,12 +54,6 @@ public sealed class JintObjectConverter : IObjectConverter
{ {
result = null!; result = null!;
if (value is Enum)
{
result = value.ToString();
return true;
}
switch (value) switch (value)
{ {
case IUser user: case IUser user:

31
backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Internal/JsonMapper.cs

@ -6,7 +6,6 @@
// ========================================================================== // ==========================================================================
using System.Collections; using System.Collections;
using System.Globalization;
using Jint; using Jint;
using Jint.Native; using Jint.Native;
using Jint.Native.Object; using Jint.Native.Object;
@ -18,10 +17,6 @@ namespace Squidex.Domain.Apps.Core.Scripting.Internal;
public static class JsonMapper public static class JsonMapper
{ {
private sealed class JsonObjectInstance(Engine engine) : ObjectInstance(engine)
{
}
public static JsValue Map(JsonValue value, Engine engine) public static JsValue Map(JsonValue value, Engine engine)
{ {
switch (value.Value) switch (value.Value)
@ -33,9 +28,9 @@ public static class JsonMapper
case false: case false:
return JsBoolean.False; return JsBoolean.False;
case double n: case double n:
return new JsNumber(n); return JsNumber.Create(n);
case string s: case string s:
return new JsString(s); return JsString.Create(s);
case JsonObject o: case JsonObject o:
return FromObject(o, engine); return FromObject(o, engine);
case JsonArray a: case JsonArray a:
@ -58,16 +53,20 @@ public static class JsonMapper
return engine.Intrinsics.Array.Construct(target); return engine.Intrinsics.Array.Construct(target);
} }
private static JsonObjectInstance FromObject(JsonObject obj, Engine engine) private static JsObject FromObject(JsonObject obj, Engine engine)
{ {
var target = new JsonObjectInstance(engine); // Objects that are created this way and have the same keys - all content items of a schema do -
// share one description of their layout, like a class. Reading a property is then a lot faster than
// with a custom ObjectInstance class, where every single object gets its own property dictionary.
var entries = new KeyValuePair<string, JsValue>[obj.Count];
var index = 0;
foreach (var (key, value) in obj) foreach (var (key, value) in obj)
{ {
target.Set(key, Map(value, engine)); entries[index++] = new KeyValuePair<string, JsValue>(key, Map(value, engine));
} }
return target; return JsObject.CreateFromEntries(engine, entries);
} }
public static JsonValue Map(JsValue? value) public static JsonValue Map(JsValue? value)
@ -116,11 +115,15 @@ public static class JsonMapper
if (value is JsArray a) if (value is JsArray a)
{ {
var result = new JsonArray((int)a.Length); var length = a.Length;
var result = new JsonArray((int)length);
for (var i = 0; i < a.Length; i++) // The indexer reads the array storage directly. The old version converted the index to a string
// and did a full property lookup for every element.
for (var i = 0u; i < length; i++)
{ {
result.Add(Map(a.Get(i.ToString(CultureInfo.InvariantCulture)))); result.Add(Map(a[i]));
} }
return result; return result;

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

@ -143,10 +143,14 @@ public sealed class JintScriptEngine(IMemoryCache cache, IOptions<JintScriptOpti
var engine = new Engine(engineOptions => var engine = new Engine(engineOptions =>
{ {
engineOptions.AddObjectConverter(JintObjectConverter.Instance); engineOptions.AddObjectConverter(JintObjectConverter.Instance, JintObjectConverter.HandledTypes);
engineOptions.AllowClrWrite(!options.Readonly); engineOptions.AllowClrWrite(!options.Readonly);
// Converts enums to their name, e.g. "Published". This was done by JintObjectConverter before.
engineOptions.Interop.EnumConversion = EnumConversionMode.String;
engineOptions.SetTypeConverter(engine => new CustomClrConverter(engine)); engineOptions.SetTypeConverter(engine => new CustomClrConverter(engine));
engineOptions.SetReferencesResolver(NullPropagation.Instance); engineOptions.SetReferencesResolver(NullPropagation.Instance, NullPropagation.Interests);
engineOptions.Strict(); engineOptions.Strict();
if (!Debugger.IsAttached) if (!Debugger.IsAttached)

22
backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/NullPropagation.cs

@ -14,8 +14,30 @@ namespace Squidex.Domain.Apps.Core.Scripting;
public sealed class NullPropagation : IReferenceResolver public sealed class NullPropagation : IReferenceResolver
{ {
/// <summary>
/// The cases this resolver actually handles.
/// </summary>
/// <remarks>
/// Without this list Jint has to assume that we want to see every property read and turns off its read
/// caches for the whole engine. But <see cref="TryPropertyReference"/> only ever does something when the
/// value is null or undefined, so the other cases can be left to Jint. Behavior does not change: for a
/// case that is not listed here Jint behaves as if no resolver was registered at all.
/// </remarks>
public const ReferenceResolverInterests Interests =
ReferenceResolverInterests.NullishPropertyBase |
ReferenceResolverInterests.UnresolvableReference |
ReferenceResolverInterests.NonCallableCallee;
public static readonly NullPropagation Instance = new NullPropagation(); public static readonly NullPropagation Instance = new NullPropagation();
/// <summary>
/// Called when a name does not exist, so that reading an unknown variable does not throw.
/// </summary>
/// <remarks>
/// The returned base is not <c>undefined</c> here but an internal Jint marker string that reads
/// <c>[[Unresolvable]]</c>. That is what scripts have always seen, so it is kept as it is and covered by
/// a test. Returning <c>undefined</c> would be nicer, but would change behavior for existing scripts.
/// </remarks>
public bool TryUnresolvableReference(Engine engine, Reference reference, out JsValue value) public bool TryUnresolvableReference(Engine engine, Reference reference, out JsValue value)
{ {
value = reference.Base; value = reference.Base;

11
backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/WritableContext.cs

@ -1,4 +1,4 @@
// ========================================================================== // ==========================================================================
// Squidex Headless CMS // Squidex Headless CMS
// ========================================================================== // ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt) // Copyright (c) Squidex UG (haftungsbeschraenkt)
@ -8,6 +8,7 @@
using Jint; using Jint;
using Jint.Native; using Jint.Native;
using Jint.Native.Object; using Jint.Native.Object;
using Jint.Runtime.Descriptors;
namespace Squidex.Domain.Apps.Core.Scripting; namespace Squidex.Domain.Apps.Core.Scripting;
@ -20,9 +21,15 @@ internal sealed class WritableContext : ObjectInstance
{ {
this.vars = vars; this.vars = vars;
// Adds the value, but runs the conversion only when the script reads it for the first time. Most
// scripts use a few of these variables and some of them are expensive, e.g. the user variable walks
// and groups all claims. The properties themselves are added right away, so key order, enumeration
// and "in" checks stay the same.
foreach (var (key, item) in vars) foreach (var (key, item) in vars)
{ {
base.Set(key, FromObject(engine, item), this); SetOwnProperty(key, PropertyDescriptor.CreateLazy(
(Engine: engine, Item: item),
static state => FromObject(state.Engine, state.Item)));
} }
} }

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

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

132
backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/ContentDataObjectTests.cs

@ -409,6 +409,138 @@ public class ContentDataObjectTests
ExecuteScript([], script); ExecuteScript([], script);
} }
[Fact]
public void Should_answer_in_operator_for_field_values()
{
const string script = @"
('iv' in data.string) + ',' + ('unknown' in data.string);
";
var actual = EvaluateScript(CreateContent(), script);
Assert.Equal("true,false", actual);
}
[Fact]
public void Should_answer_has_own_property_for_field_values()
{
const string script = @"
data.string.hasOwnProperty('iv') + ',' + data.string.hasOwnProperty('unknown');
";
var actual = EvaluateScript(CreateContent(), script);
Assert.Equal("true,false", actual);
}
[Fact]
public void Should_list_field_value_keys()
{
const string script = @"
Object.keys(data.string).join(',');
";
var actual = EvaluateScript(CreateContent(), script);
Assert.Equal("iv,de", actual);
}
[Fact]
public void Should_report_field_values_as_enumerable()
{
const string script = @"
data.string.propertyIsEnumerable('iv') + ',' + data.string.propertyIsEnumerable('unknown');
";
var actual = EvaluateScript(CreateContent(), script);
Assert.Equal("true,false", actual);
}
[Fact]
public void Should_stringify_field_values()
{
const string script = @"
JSON.stringify(data.string);
";
var actual = EvaluateScript(CreateContent(), script);
Assert.Equal("{\"iv\":\"1\",\"de\":\"2\"}", actual);
}
[Fact]
public void Should_stringify_content_data()
{
const string script = @"
JSON.stringify(data);
";
var actual = EvaluateScript(CreateContent(), script);
Assert.Equal("{\"string\":{\"iv\":\"1\",\"de\":\"2\"},\"number\":{\"iv\":42}}", actual);
}
[Fact]
public void Should_spread_field_values()
{
const string script = @"
JSON.stringify({ ...data.string });
";
var actual = EvaluateScript(CreateContent(), script);
Assert.Equal("{\"iv\":\"1\",\"de\":\"2\"}", actual);
}
[Fact]
public void Should_not_see_deleted_field_values()
{
const string script = @"
delete data.string.de;
('de' in data.string) + ',' + Object.keys(data.string).join(',');
";
var actual = EvaluateScript(CreateContent(), script);
Assert.Equal("false,iv", actual);
}
[Fact]
public void Should_see_added_field_values()
{
const string script = @"
data.string.en = '3';
('en' in data.string) + ',' + Object.keys(data.string).join(',');
";
var actual = EvaluateScript(CreateContent(), script);
Assert.Equal("true,iv,de,en", actual);
}
private static ContentData CreateContent()
{
return
new ContentData()
.AddField("string",
new ContentFieldData()
.AddInvariant("1")
.AddLocalized("de", "2"))
.AddField("number",
new ContentFieldData()
.AddInvariant(42));
}
private static object? EvaluateScript(ContentData original, string script)
{
var engine = new Engine(o => o.Strict());
engine.SetValue("data", new ContentDataObject(engine, original));
return engine.Evaluate(script).ToObject();
}
private static ContentData ExecuteScript(ContentData original, string script) private static ContentData ExecuteScript(ContentData original, string script)
{ {
var engine = new Engine(o => o.Strict()); var engine = new Engine(o => o.Strict());

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

@ -725,4 +725,497 @@ public class JintScriptEngineTests : IClassFixture<TranslationsFixture>
Assert.Equal(42.0, result.Value); Assert.Equal(42.0, result.Value);
} }
[Fact]
public void Should_not_throw_if_reading_undeclared_identifier()
{
// Reading an unknown name does not throw, it returns an internal Jint marker string. That is odd,
// but it is what scripts have always seen here, see NullPropagation.TryUnresolvableReference.
const string script = @"
String(unknownName) + '|' + (typeof unknownName);
";
var actual = sut.Execute(new ScriptVars(), script);
Assert.Equal(JsonValue.Create("[[Unresolvable]]|undefined"), actual);
}
[Fact]
public void Should_null_propagate_over_nullish_property_base()
{
var vars = new ScriptVars
{
["value"] = 13,
};
const string script = @"
ctx.unknown.deeper.evenDeeper === undefined;
";
var actual = sut.Execute(vars, script, new ScriptOptions { AsContext = true });
Assert.Equal(JsonValue.True, actual);
}
[Fact]
public void Should_chain_call_over_nullish_property_base()
{
var vars = new ScriptVars
{
["value"] = 13,
};
const string script = @"
ctx.unknown.deeper.someMethod() === undefined;
";
var actual = sut.Execute(vars, script, new ScriptOptions { AsContext = true });
Assert.Equal(JsonValue.True, actual);
}
[Fact]
public void Should_return_base_if_calling_non_callable_member()
{
var vars = new ScriptVars
{
["value"] = "squidex",
};
const string script = @"
ctx.value.notAFunction();
";
var actual = sut.Execute(vars, script, new ScriptOptions { AsContext = true });
Assert.Equal(JsonValue.Create("squidex"), actual);
}
[Fact]
public void Should_not_change_normal_member_reads_and_calls()
{
var vars = new ScriptVars
{
["value"] = JsonValue.Create(new JsonObject().Add("name", JsonValue.Create("squidex"))),
};
const string script = @"
ctx.value.name.toUpperCase();
";
var actual = sut.Execute(vars, script, new ScriptOptions { AsContext = true });
Assert.Equal(JsonValue.Create("SQUIDEX"), actual);
}
[Fact]
public void Should_convert_enum_to_name()
{
var vars = new ScriptVars
{
["value"] = ScriptScope.ContentScript,
};
const string script = @"
value;
";
var actual = sut.Execute(vars, script);
Assert.Equal(JsonValue.Create("ContentScript"), actual);
}
[Fact]
public void Should_convert_flags_enum_to_names()
{
var vars = new ScriptVars
{
["value"] = ScriptScope.ContentScript | ScriptScope.Transform,
};
const string script = @"
value;
";
var actual = sut.Execute(vars, script);
Assert.Equal(JsonValue.Create("ContentScript, Transform"), actual);
}
[Fact]
public void Should_convert_enum_member_of_wrapped_object_to_name()
{
var vars = new ScriptVars
{
["value"] = new { scope = ScriptScope.Transform },
};
const string script = @"
value.scope;
";
var actual = sut.Execute(vars, script);
Assert.Equal(JsonValue.Create("Transform"), actual);
}
[Fact]
public void Should_project_json_object_with_source_key_order()
{
var vars = new ScriptVars
{
["value"] = CreateJson(),
};
const string script = @"
Object.keys(ctx.value).join(',');
";
var actual = sut.Execute(vars, script, new ScriptOptions { AsContext = true });
Assert.Equal(JsonValue.Create("name,count,nested,items"), actual);
}
[Fact]
public void Should_stringify_projected_json_object()
{
var vars = new ScriptVars
{
["value"] = CreateJson(),
};
const string script = @"
JSON.stringify(ctx.value);
";
var actual = sut.Execute(vars, script, new ScriptOptions { AsContext = true });
Assert.Equal(
JsonValue.Create("{\"name\":\"squidex\",\"count\":3,\"nested\":{\"flag\":true},\"items\":[1,2]}"),
actual);
}
[Fact]
public void Should_enumerate_projected_json_object()
{
var vars = new ScriptVars
{
["value"] = CreateJson(),
};
const string script = @"
var actual = [];
for (var key in ctx.value) {
actual.push(key + '=' + (typeof ctx.value[key]));
}
actual.join(',');
";
var actual = sut.Execute(vars, script, new ScriptOptions { AsContext = true });
Assert.Equal(
JsonValue.Create("name=string,count=number,nested=object,items=object"),
actual);
}
[Fact]
public void Should_allow_mutation_of_projected_json_object()
{
var vars = new ScriptVars
{
["value"] = CreateJson(),
};
const string script = @"
ctx.value.name = 'changed';
ctx.value.added = 42;
delete ctx.value.count;
ctx.value;
";
var actual = sut.Execute(vars, script, new ScriptOptions { AsContext = true });
var expected =
JsonValue.Create(
new JsonObject()
.Add("name", JsonValue.Create("changed"))
.Add("nested", JsonValue.Create(new JsonObject().Add("flag", JsonValue.True)))
.Add("items", JsonValue.Create(new JsonArray().Add(JsonValue.Create(1)).Add(JsonValue.Create(2))))
.Add("added", JsonValue.Create(42)));
Assert.Equal(expected, actual);
}
[Fact]
public void Should_round_trip_projected_json_object()
{
var json = CreateJson();
var vars = new ScriptVars
{
["value"] = json,
};
const string script = @"
ctx.value;
";
var actual = sut.Execute(vars, script, new ScriptOptions { AsContext = true });
Assert.Equal(json, actual);
}
[Fact]
public void Should_enumerate_context_keys()
{
const string script = @"
Object.keys(ctx).join(',');
";
var actual = sut.Execute(CreateVars(), script, new ScriptOptions { AsContext = true });
Assert.Equal(JsonValue.Create("number,text,json,user"), actual);
}
[Fact]
public void Should_answer_in_operator_for_context_keys()
{
const string script = @"
('json' in ctx) + ',' + ('unknown' in ctx);
";
var actual = sut.Execute(CreateVars(), script, new ScriptOptions { AsContext = true });
Assert.Equal(JsonValue.Create("true,false"), actual);
}
[Fact]
public void Should_report_types_of_context_values()
{
const string script = @"
var actual = [];
for (var key in ctx) {
actual.push(key + '=' + (typeof ctx[key]));
}
actual.join(',');
";
var actual = sut.Execute(CreateVars(), script, new ScriptOptions { AsContext = true });
Assert.Equal(JsonValue.Create("number=number,text=string,json=object,user=object"), actual);
}
[Fact]
public void Should_read_context_values()
{
const string script = @"
ctx.number + '|' + ctx.text + '|' + ctx.json.name + '|' + ctx.user.id;
";
var actual = sut.Execute(CreateVars(), script, new ScriptOptions { AsContext = true });
Assert.Equal(JsonValue.Create("13|hello|squidex|user1"), actual);
}
[Fact]
public void Should_write_context_value_through_to_vars()
{
var vars = CreateVars();
const string script = @"
ctx.number = ctx.number * 2;
";
sut.Execute(vars, script, new ScriptOptions { AsContext = true });
Assert.Equal(26.0, vars["number"]);
}
[Fact]
public void Should_delete_context_value()
{
var vars = CreateVars();
const string script = @"
delete ctx.text;
Object.keys(ctx).join(',');
";
var actual = sut.Execute(vars, script, new ScriptOptions { AsContext = true });
Assert.Equal(JsonValue.Create("number,json,user"), actual);
}
[Fact]
public void Should_not_map_unread_variable()
{
var principal = new CountingPrincipal();
var vars = new ScriptVars
{
["number"] = 13,
["user"] = principal,
};
const string script = @"
number + 1;
";
var actual = sut.Execute(vars, script);
Assert.Equal(JsonValue.Create(14), actual);
Assert.Equal(0, principal.Reads);
}
[Fact]
public void Should_see_unread_variable_in_enumeration()
{
var principal = new CountingPrincipal();
var vars = new ScriptVars
{
["user"] = principal,
};
const string script = @"
('user' in globalThis) + ',' + (Object.getOwnPropertyNames(globalThis).indexOf('user') >= 0);
";
var actual = sut.Execute(vars, script);
Assert.Equal(JsonValue.Create("true,true"), actual);
Assert.Equal(0, principal.Reads);
}
[Fact]
public void Should_map_variable_on_first_read()
{
var principal = new CountingPrincipal();
var vars = new ScriptVars
{
["user"] = principal,
};
const string script = @"
user.id;
";
var actual = sut.Execute(vars, script);
Assert.Equal(JsonValue.Create("user1"), actual);
Assert.True(principal.Reads > 0);
}
[Fact]
public void Should_not_map_unread_context_variable()
{
var principal = new CountingPrincipal();
var vars = new ScriptVars
{
["number"] = 13,
["user"] = principal,
};
const string script = @"
ctx.number + 1;
";
var actual = sut.Execute(vars, script, new ScriptOptions { AsContext = true });
Assert.Equal(JsonValue.Create(14), actual);
Assert.Equal(0, principal.Reads);
}
[Fact]
public void Should_see_unread_context_variable_in_enumeration()
{
var principal = new CountingPrincipal();
var vars = new ScriptVars
{
["number"] = 13,
["user"] = principal,
};
const string script = @"
Object.keys(ctx).join(',') + '|' + ('user' in ctx);
";
var actual = sut.Execute(vars, script, new ScriptOptions { AsContext = true });
Assert.Equal(JsonValue.Create("number,user|true"), actual);
Assert.Equal(0, principal.Reads);
}
[Fact]
public void Should_map_context_variable_on_first_read()
{
var principal = new CountingPrincipal();
var vars = new ScriptVars
{
["user"] = principal,
};
const string script = @"
ctx.user.id;
";
var actual = sut.Execute(vars, script, new ScriptOptions { AsContext = true });
Assert.Equal(JsonValue.Create("user1"), actual);
Assert.True(principal.Reads > 0);
}
private sealed class CountingPrincipal : ClaimsPrincipal
{
public int Reads { get; private set; }
public CountingPrincipal()
: base(new ClaimsIdentity(
[
new Claim(OpenIdClaims.Subject, "user1"),
new Claim(OpenIdClaims.Name, "user"),
], "Squidex"))
{
}
public override IEnumerable<Claim> Claims
{
get
{
Reads++;
return base.Claims;
}
}
}
private static ScriptVars CreateVars()
{
return new ScriptVars
{
["number"] = 13,
["text"] = "hello",
["json"] = CreateJson(),
["user"] = new ClaimsPrincipal(
new ClaimsIdentity(
[
new Claim(OpenIdClaims.Subject, "user1"),
new Claim(OpenIdClaims.Name, "user"),
], "Squidex")),
};
}
private static JsonValue CreateJson()
{
return JsonValue.Create(
new JsonObject()
.Add("name", JsonValue.Create("squidex"))
.Add("count", JsonValue.Create(3))
.Add("nested", JsonValue.Create(new JsonObject().Add("flag", JsonValue.True)))
.Add("items", JsonValue.Create(new JsonArray().Add(JsonValue.Create(1)).Add(JsonValue.Create(2)))));
}
} }

52
backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/JsonMapperTests.cs

@ -0,0 +1,52 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Jint;
using Jint.Native.Object;
using Squidex.Domain.Apps.Core.Scripting.Internal;
using Squidex.Infrastructure.Json.Objects;
namespace Squidex.Domain.Apps.Core.Operations.Scripting;
public class JsonMapperTests
{
[Fact]
public void Should_map_json_objects_into_a_shared_shape()
{
var engine = new Engine(o => o.Strict());
var mapped = (ObjectInstance)JsonMapper.Map(CreateJson(), engine);
var nested = (ObjectInstance)mapped.Get("nested");
// Sharing the layout is what makes reading properties of many content items fast. It only affects
// performance and never behavior, which is why it is asserted here: building these objects with a
// custom ObjectInstance class again would silently undo it and no other test would notice.
Assert.True(engine.Advanced.HasSharedShape(mapped));
Assert.True(engine.Advanced.HasSharedShape(nested));
}
[Fact]
public void Should_share_the_shape_between_objects_of_the_same_shape()
{
var engine = new Engine(o => o.Strict());
var first = (ObjectInstance)JsonMapper.Map(CreateJson(), engine);
var second = (ObjectInstance)JsonMapper.Map(CreateJson(), engine);
Assert.True(engine.Advanced.HasSharedShape(first));
Assert.True(engine.Advanced.HasSharedShape(second));
}
private static JsonValue CreateJson()
{
return JsonValue.Create(
new JsonObject()
.Add("name", JsonValue.Create("squidex"))
.Add("count", JsonValue.Create(3))
.Add("nested", JsonValue.Create(new JsonObject().Add("flag", JsonValue.True))));
}
}

33
backend/tests/Squidex.Domain.Apps.Core.Tests/TestHelpers/JintHostContractVerification.cs

@ -0,0 +1,33 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Runtime.CompilerServices;
namespace Squidex.Domain.Apps.Core.TestHelpers;
/// <summary>
/// Turns on Jint's self checks for this test assembly.
/// </summary>
/// <remarks>
/// Our ContentWrapper classes and the object converter implement Jint extension points where Jint relies on
/// our answers being consistent, without checking them - checking would cost as much as the shortcut saves.
/// A mistake there is silent in production: a key can disappear from Object.keys, or a converted type can be
/// skipped. With this switch on Jint verifies the answers and throws on the first mismatch, so a mistake
/// fails a test instead.
/// <para>
/// The switch has to be set before the first Jint type is used, which is what the module initializer
/// guarantees. It stays off in production, where the checks would only cost performance.
/// </para>
/// </remarks>
internal static class JintHostContractVerification
{
[ModuleInitializer]
internal static void Enable()
{
AppContext.SetSwitch("Jint.EnableHostContractVerification", true);
}
}
Loading…
Cancel
Save