Browse Source

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
pull/1326/head
Marko Lahma 1 month ago
parent
commit
2fa24b5a5b
  1. 132
      backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/ContentDataObjectTests.cs
  2. 347
      backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/JintScriptEngineTests.cs

132
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());

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

@ -725,4 +725,351 @@ public class JintScriptEngineTests : IClassFixture<TranslationsFixture>
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)))));
}
}

Loading…
Cancel
Save