From 2fa24b5a5bbfaade3a7729f29fa584c8a4bc27c0 Mon Sep 17 00:00:00 2001 From: Marko Lahma Date: Wed, 29 Jul 2026 14:14:54 +0300 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_016uV6H9cTntzsoKiaJRBn4f --- .../Scripting/ContentDataObjectTests.cs | 132 +++++++ .../Scripting/JintScriptEngineTests.cs | 347 ++++++++++++++++++ 2 files changed, 479 insertions(+) diff --git a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/ContentDataObjectTests.cs b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/ContentDataObjectTests.cs index cb0ee139a..a89f82b13 100644 --- a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/ContentDataObjectTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/ContentDataObjectTests.cs @@ -409,6 +409,138 @@ public class ContentDataObjectTests 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) { var engine = new Engine(o => o.Strict()); diff --git a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/JintScriptEngineTests.cs b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/JintScriptEngineTests.cs index 80395b4e5..365f44cb3 100644 --- a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/JintScriptEngineTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/JintScriptEngineTests.cs @@ -725,4 +725,351 @@ public class JintScriptEngineTests : IClassFixture Assert.Equal(42.0, result.Value); } + + [Fact] + public void Should_not_throw_if_reading_undeclared_identifier() + { + // The null propagation resolver answers an unresolvable reference with the reference base, which is + // Jint's internal sentinel. The value is odd, but it is what scripts have always seen and the point + // of the test is that the read does not throw a reference error. + 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); + } + + 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))))); + } }