From c2ffca1ad3f9808423fcedb41a95f616005ca484 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Fri, 30 Oct 2026 02:49:34 +0100 Subject: [PATCH 1/4] Honor configured regex timeouts in prepared scripts --- CHANGELOG.md | 4 ++ .../EngineConfigurationTests.cs | 46 +++++++++++++++++++ src/AngleSharp.Js/Cache/ScriptCache.cs | 40 ++++++++++++---- src/AngleSharp.Js/EngineInstance.cs | 4 +- 4 files changed, 85 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 09d0aed..f1b4f45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +# 1.1.3 + +- Fixed prepared regular-expression literals ignoring the timeout configured during engine creation + # 1.1.2 Released on Sunday, October 25 2026. diff --git a/src/AngleSharp.Js.Tests/EngineConfigurationTests.cs b/src/AngleSharp.Js.Tests/EngineConfigurationTests.cs index cc69a88..3841145 100644 --- a/src/AngleSharp.Js.Tests/EngineConfigurationTests.cs +++ b/src/AngleSharp.Js.Tests/EngineConfigurationTests.cs @@ -5,6 +5,7 @@ namespace AngleSharp.Js.Tests using AngleSharp.Js.Tests.Mocks; using AngleSharp.Scripting; using Jint; + using Jint.Native; using Jint.Runtime; using NUnit.Framework; using System; @@ -14,6 +15,51 @@ namespace AngleSharp.Js.Tests public class EngineConfigurationTests { + [TestCase(false)] + [TestCase(true)] + public async Task RegexTimeoutAppliesToInlineAndExplicitScripts(Boolean inline) + { + var timeout = TimeSpan.FromMilliseconds(37); + var config = Configuration.Default + .With(new EngineCreator((window, options) => new Engine(options.RegexTimeoutInterval(timeout)))) + .WithJs() + .WithEventLoop(); + + using (var context = BrowsingContext.New(config)) + { + var document = await context.OpenAsync(request => request.Content(inline + ? "" + : "")).ConfigureAwait(false); + var expression = (JsRegExp)document.ExecuteScript(inline ? "configuredRegex" : "/explicitEngineTimeout/"); + + Assert.AreEqual(timeout, expression.Value.MatchTimeout); + } + } + + [TestCase(23, 71)] + [TestCase(71, 23)] + public async Task CachedScriptsKeepEachEngineRegexTimeout(Int32 firstMilliseconds, Int32 secondMilliseconds) + { + var source = "/cachedEngineTimeout" + firstMilliseconds + "/"; + + foreach (var milliseconds in new[] { firstMilliseconds, secondMilliseconds, firstMilliseconds }) + { + var timeout = TimeSpan.FromMilliseconds(milliseconds); + var config = Configuration.Default + .With(new EngineCreator((window, options) => new Engine(options.RegexTimeoutInterval(timeout)))) + .WithJs() + .WithEventLoop(); + + using (var context = BrowsingContext.New(config)) + { + var document = await context.OpenNewAsync().ConfigureAwait(false); + var expression = (JsRegExp)document.ExecuteScript(source); + + Assert.AreEqual(timeout, expression.Value.MatchTimeout); + } + } + } + [Test] public async Task ConstraintsAreInstalledBeforeInlineScriptsRun() { diff --git a/src/AngleSharp.Js/Cache/ScriptCache.cs b/src/AngleSharp.Js/Cache/ScriptCache.cs index 99faff4..8a63912 100644 --- a/src/AngleSharp.Js/Cache/ScriptCache.cs +++ b/src/AngleSharp.Js/Cache/ScriptCache.cs @@ -9,8 +9,8 @@ namespace AngleSharp.Js.Cache /// /// Caches the parsed and analyzed form of scripts. A prepared script is /// documented by Jint as reusable and thread-safe, so the same one can serve - /// every document that runs the same source - a page loading a library ends up - /// parsing it once instead of once per document. + /// every document that runs the same source with the same regex timeout - a + /// page loading a library ends up parsing it once instead of once per document. /// static class ScriptCache { @@ -19,24 +19,29 @@ static class ScriptCache // seen first: libraries are referenced early and by many documents. private const Int32 Capacity = 32; - private static readonly ConcurrentDictionary> _scripts = - new ConcurrentDictionary>(StringComparer.Ordinal); + private static readonly ConcurrentDictionary> _scripts = + new ConcurrentDictionary>(); /// /// Gets the prepared form of the given source. An invalid result means the /// source could not be prepared and should be handed to the engine as text, /// so that the engine reports the syntax error itself. /// - public static Prepared"; + await BrowsingContext.New(cfg).OpenAsync(m => m.Content(html)); + var output = console.Content.ToString(); + + StringAssert.Contains("e", output); + StringAssert.Contains("w", output); + StringAssert.Contains("shown", output); + StringAssert.DoesNotContain("hidden", output); + StringAssert.Contains("default: 2", output); + } + + [TestCase("var declared = 1; String(window.declared)", "1")] + [TestCase("implicitGlobal = 2; String(window.implicitGlobal)", "2")] + [TestCase("function declaredFunction() {} typeof window.declaredFunction", "function")] + [TestCase("String(window.Promise === Promise)", "true")] + [TestCase("String(window.JSON === JSON)", "true")] + [TestCase("String('Math' in window)", "true")] + [TestCase("var item = 3; String(window.item)", "3")] + [TestCase("window.assigned = 4; delete window.assigned; typeof assigned", "undefined")] + public async Task WindowSeesTheGlobalsOfScripts(String script, String expected) + { + var context = BrowsingContext.New(Configuration.Default.WithJs()); + var document = await context.OpenAsync(m => m.Content("

x

")); + + Assert.AreEqual(expected, document.ExecuteScript(script)?.ToString()); + } + + [TestCase("document.querySelectorAll('p').item(1).id", "b")] + [TestCase("document.getElementsByTagName('p').item(0).id", "a")] + [TestCase("document.body.children.item(1).id", "b")] + [TestCase("document.body.children.namedItem('b').id", "b")] + [TestCase("document.querySelector('p').attributes.item(0).name", "id")] + [TestCase("typeof NodeList.prototype.item", "function")] + public async Task NamedIndexersAreMethods(String script, String expected) + { + var context = BrowsingContext.New(Configuration.Default.WithJs()); + var document = await context.OpenAsync(m => m.Content("

")); + + Assert.AreEqual(expected, document.ExecuteScript(script)?.ToString()); + } + + [Test] + public async Task ErrorOfAMemberKeepsItsMessage() + { + var result = await "(function () { try { document.querySelector('!'); } catch (e) { return e.message; } })()".EvalScriptAsync(); + Assert.IsNotEmpty(result); + } + + [Test] + public async Task PerformanceNowIsMonotonic() + { + var result = await "(function () { var a = performance.now(); var b = performance.now(); return (a >= 0 && b >= a && typeof performance.timeOrigin === 'number'); })()".EvalScriptAsync(); + Assert.AreEqual("True", result); + } + + [Test] + public async Task AnimationFrameCallbackReceivesTheWindowAndATime() + { + var context = BrowsingContext.New(Configuration.Default.WithJs().WithEventLoop()); + var document = await context.OpenAsync(m => m.Content("")); + + for (var i = 0; i < 100 && document.Title.Length == 0; i++) + { + await Task.Delay(20); + } + + Assert.AreEqual("true,number", document.Title); + } + + [Test] + public async Task CancelledAnimationFrameDoesNotRun() + { + var context = BrowsingContext.New(Configuration.Default.WithJs().WithEventLoop()); + var document = await context.OpenAsync(m => m.Content("")); + + for (var i = 0; i < 100 && document.Title.Length == 0; i++) + { + await Task.Delay(20); + } + + Assert.AreEqual("done", document.Title); + } + + [Test] + public async Task QueuedMicrotaskRunsAfterTheScript() + { + var context = BrowsingContext.New(Configuration.Default.WithJs().WithEventLoop()); + var document = await context.OpenAsync(m => m.Content("")); + + for (var i = 0; i < 100 && document.Title != "abc"; i++) + { + await Task.Delay(20); + } + + Assert.AreEqual("abc", document.Title); + } + } +} diff --git a/src/AngleSharp.Js/Cache/DomShapeCache.cs b/src/AngleSharp.Js/Cache/DomShapeCache.cs index 0dc7399..fc229da 100644 --- a/src/AngleSharp.Js/Cache/DomShapeCache.cs +++ b/src/AngleSharp.Js/Cache/DomShapeCache.cs @@ -172,7 +172,8 @@ public DomShape Build() } var indexers = _numericIndexer != null || _stringIndexerGetter != null || _stringIndexerSetter != null - ? new DomIndexers(_numericIndexer, _stringIndexerGetter, _stringIndexerSetter) + ? new DomIndexers(_numericIndexer, _stringIndexerGetter, _stringIndexerSetter, + typeof(IStringMap).IsAssignableFrom(_type) ? DomIndexers.ToDatasetName : null) : null; return new DomShape(_type, _baseType, _name, builder.Build(), indexers, constructor); @@ -196,7 +197,12 @@ private void SetExtensionMembers() return; } - foreach (var type in _libs.Assemblies.GetExtensionTypes(_name)) + var own = typeof(DomShapeBuilder).GetTypeInfo().Assembly; + var types = _libs.Assemblies.GetExtensionTypes(_name).ToList(); + + // The members this library brings itself are partly stubs standing in for a missing + // layout (offsetHeight is 0, ...), so a library implementing them (the renderer) wins. + foreach (var type in types.Where(m => m.GetTypeInfo().Assembly != own).Concat(types.Where(m => m.GetTypeInfo().Assembly == own))) { var typeInfo = type.GetTypeInfo(); SetExtensionMethods(typeInfo.DeclaredMethods); @@ -246,7 +252,10 @@ private void SetExtensionMethods(IEnumerable methods) } else if (value.Other != null) { - SetMethod(name, value.Other); + if (!IsDeclared(name)) + { + Define(name, new MethodMember(value.Overloads.ToArray())); + } } } } @@ -289,6 +298,21 @@ private void SetNormalProperties(IEnumerable properties) SetIndexer(property, indexParameters); } + if (indexParameters.Length > 0) + { + // A named indexer is a getter operation in WebIDL (getter Node? item(index)), + // so its name is a method taking the index - an accessor could never receive it. + if (property.GetMethod != null) + { + foreach (var name in names) + { + SetMethod(name, property.GetMethod); + } + } + + continue; + } + foreach (var name in names) { SetProperty(name, property.GetMethod, property.SetMethod, putsForward, sameObject); @@ -382,16 +406,22 @@ private abstract class Member private sealed class MethodMember : Member { - private readonly MethodInfo _method; + private readonly MethodInfo[] _overloads; - public MethodMember(MethodInfo method) + public MethodMember(params MethodInfo[] overloads) { - _method = method; + _overloads = overloads; } public override void Declare(JsObjectShape.Builder builder, String name, Boolean perRealm) { - var method = _method; + if (_overloads.Length > 1) + { + DeclareOverloads(builder, name, perRealm); + return; + } + + var method = _overloads[0]; if (perRealm) { @@ -422,6 +452,38 @@ private static JsValue Bind(ObjectInstance prototype, String name, MethodInfo me return new ClrFunction(instance.Jint, name, (thisObject, arguments) => instance.Call(method, thisObject, arguments)); } + + // The overload is picked per call from the arguments, as WebIDL's overload + // resolution does. + private void DeclareOverloads(JsObjectShape.Builder builder, String name, Boolean perRealm) + { + var overloads = _overloads; + + if (perRealm) + { + builder.PerRealmSlot( + name, + prototype => + { + var instance = DomPrototypeState.Of(prototype).Instance; + return new ClrFunction(instance.Jint, name, (thisObject, arguments) => + instance.Call(overloads.SelectOverload(arguments), thisObject, arguments)); + }, + enumerable: false, + writable: false, + configurable: false); + } + else + { + builder.Method( + name, + (thisObject, arguments) => EngineExtensions.CallShared(overloads.SelectOverload(arguments), thisObject, arguments), + length: 0, + enumerable: false, + writable: false, + configurable: false); + } + } } private sealed class PropertyMember : Member diff --git a/src/AngleSharp.Js/Dom/Console.cs b/src/AngleSharp.Js/Dom/Console.cs index 5dc3321..7287772 100644 --- a/src/AngleSharp.Js/Dom/Console.cs +++ b/src/AngleSharp.Js/Dom/Console.cs @@ -1,15 +1,25 @@ using AngleSharp.Attributes; using AngleSharp.Dom; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; namespace AngleSharp.Js.Dom { /// - /// Defines the console. + /// Defines the console. Every method of the console standard is present, as + /// scripts routinely wrap or call any of them (e.g. console.error or console.info) + /// and would fail on the first missing one. All output goes to the logger. /// [DomName("Console")] public sealed class Console { + private const String DefaultLabel = "default"; + private readonly IConsoleLogger _logger; + private readonly Dictionary _counters = new Dictionary(); + private readonly Dictionary _timers = new Dictionary(); /// /// Creates a new console. @@ -27,9 +37,171 @@ public Console(IWindow window) /// /// [DomName("log")] - public void Log(params object[] objs) + public void Log(params Object[] objs) { _logger?.Log(objs); } + + /// + /// Outputs an error message to the console. + /// + [DomName("error")] + public void Error(params Object[] objs) => Log(objs); + + /// + /// Outputs a warning to the console. + /// + [DomName("warn")] + public void Warn(params Object[] objs) => Log(objs); + + /// + /// Outputs an informational message to the console. + /// + [DomName("info")] + public void Info(params Object[] objs) => Log(objs); + + /// + /// Outputs a debug message to the console. + /// + [DomName("debug")] + public void Debug(params Object[] objs) => Log(objs); + + /// + /// Outputs a trace message to the console. + /// + [DomName("trace")] + public void Trace(params Object[] objs) => Log(objs); + + /// + /// Outputs the given object to the console. + /// + [DomName("dir")] + public void Dir(params Object[] objs) => Log(objs); + + /// + /// Outputs the given node to the console. + /// + [DomName("dirxml")] + public void DirXml(params Object[] objs) => Log(objs); + + /// + /// Outputs the given tabular data to the console. + /// + [DomName("table")] + public void Table(params Object[] objs) => Log(objs); + + /// + /// Outputs the given data if the assertion fails. + /// + [DomName("assert")] + public void Assert(Boolean condition = false, params Object[] objs) + { + if (!condition) + { + var data = new Object[(objs?.Length ?? 0) + 1]; + data[0] = "Assertion failed"; + objs?.CopyTo(data, 1); + Log(data); + } + } + + /// + /// Starts a new group, which is not indented by the logger. + /// + [DomName("group")] + public void Group(params Object[] objs) => LogIfAny(objs); + + /// + /// Starts a new collapsed group, which is not indented by the logger. + /// + [DomName("groupCollapsed")] + public void GroupCollapsed(params Object[] objs) => LogIfAny(objs); + + /// + /// Ends the current group. + /// + [DomName("groupEnd")] + public void GroupEnd() + { + } + + /// + /// Clears the console, which the logger is unable to do. + /// + [DomName("clear")] + public void Clear() + { + } + + /// + /// Counts the number of calls with the given label and outputs it. + /// + [DomName("count")] + public void Count(String label = DefaultLabel) + { + _counters.TryGetValue(label, out var count); + _counters[label] = ++count; + Log(label + ": " + count.ToString(CultureInfo.InvariantCulture)); + } + + /// + /// Resets the counter with the given label. + /// + [DomName("countReset")] + public void CountReset(String label = DefaultLabel) + { + _counters.Remove(label); + } + + /// + /// Starts a timer with the given label. + /// + [DomName("time")] + public void Time(String label = DefaultLabel) + { + if (!_timers.ContainsKey(label)) + { + _timers[label] = Stopwatch.StartNew(); + } + } + + /// + /// Outputs the elapsed time of the timer with the given label. + /// + [DomName("timeLog")] + public void TimeLog(String label = DefaultLabel, params Object[] objs) + { + if (_timers.TryGetValue(label, out var timer)) + { + var data = new Object[(objs?.Length ?? 0) + 1]; + data[0] = FormatElapsed(label, timer); + objs?.CopyTo(data, 1); + Log(data); + } + } + + /// + /// Outputs the elapsed time of the timer with the given label and stops it. + /// + [DomName("timeEnd")] + public void TimeEnd(String label = DefaultLabel) + { + if (_timers.TryGetValue(label, out var timer)) + { + _timers.Remove(label); + Log(FormatElapsed(label, timer)); + } + } + + private void LogIfAny(Object[] objs) + { + if (objs?.Length > 0) + { + Log(objs); + } + } + + private static String FormatElapsed(String label, Stopwatch timer) => + label + ": " + timer.Elapsed.TotalMilliseconds.ToString("0.###", CultureInfo.InvariantCulture) + " ms"; } } diff --git a/src/AngleSharp.Js/Dom/FrameRequestCallback.cs b/src/AngleSharp.Js/Dom/FrameRequestCallback.cs new file mode 100644 index 0000000..182a186 --- /dev/null +++ b/src/AngleSharp.Js/Dom/FrameRequestCallback.cs @@ -0,0 +1,12 @@ +namespace AngleSharp.Js.Dom +{ + using AngleSharp.Dom; + using System; + + /// + /// The callback of requestAnimationFrame, receiving the time of the frame. + /// + /// The window, which is the receiver of the call. + /// The time of the frame, as given by performance.now(). + public delegate void FrameRequestCallback(IWindow window, Double time); +} diff --git a/src/AngleSharp.Js/Dom/Performance.cs b/src/AngleSharp.Js/Dom/Performance.cs new file mode 100644 index 0000000..32a3cb8 --- /dev/null +++ b/src/AngleSharp.Js/Dom/Performance.cs @@ -0,0 +1,29 @@ +namespace AngleSharp.Js.Dom +{ + using AngleSharp.Attributes; + using System; + using System.Diagnostics; + + /// + /// Represents the high resolution time of a window (performance.now()), measured from the + /// moment scripting started for its document. + /// + [DomName("Performance")] + public sealed class Performance + { + private readonly Stopwatch _clock = Stopwatch.StartNew(); + private readonly Double _timeOrigin = (DateTime.UtcNow - new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc)).TotalMilliseconds; + + /// + /// Gets the time of the origin in milliseconds since the Unix epoch. + /// + [DomName("timeOrigin")] + public Double TimeOrigin => _timeOrigin; + + /// + /// Gets the milliseconds elapsed since the time origin. + /// + [DomName("now")] + public Double Now() => _clock.Elapsed.TotalMilliseconds; + } +} diff --git a/src/AngleSharp.Js/Dom/WindowExtensions.cs b/src/AngleSharp.Js/Dom/WindowExtensions.cs index add3fdf..22eedc3 100644 --- a/src/AngleSharp.Js/Dom/WindowExtensions.cs +++ b/src/AngleSharp.Js/Dom/WindowExtensions.cs @@ -20,6 +20,11 @@ public static class WindowExtensions new ConditionalWeakTable(); private static readonly ConditionalWeakTable WorkerOwners = new ConditionalWeakTable(); + private static readonly ConditionalWeakTable Performances = + new ConditionalWeakTable(); + + // The interval between two animation frames of a 60 Hz display. + private const Int32 FrameInterval = 16; /// /// Posts a message. @@ -92,6 +97,55 @@ public static Console Console(this IWindow window) return Consoles.GetValue(window, w => new Console(w)); } + /// + /// Gets the high resolution time of the window. The same instance is returned + /// for the same window, so its time origin stays. + /// + [DomName("performance")] + [DomAccessor(Accessors.Getter)] + public static Performance Performance(this IWindow window) + { + return Performances.GetValue(window, _ => new Performance()); + } + + /// + /// Requests to call the given callback before the next frame. Without a display to + /// synchronize with, the frames follow the rate of a 60 Hz display. + /// + /// + /// The callback receiving the time of the frame. + /// The handle to cancel the request with. + [DomName("requestAnimationFrame")] + public static Int32 RequestAnimationFrame(this IWindow window, FrameRequestCallback callback) + { + var performance = window.Performance(); + return window.SetTimeout(w => callback?.Invoke(w, performance.Now()), FrameInterval); + } + + /// + /// Cancels a request made with requestAnimationFrame. + /// + /// + /// The handle of the request. + [DomName("cancelAnimationFrame")] + public static void CancelAnimationFrame(this IWindow window, Int32 handle) + { + window.ClearTimeout(handle); + } + + /// + /// Queues the given callback to run after the current script. It is queued as a task + /// (like setTimeout with no delay), not as a microtask, which is close enough for scripts + /// deferring work. + /// + /// + /// The callback to run. + [DomName("queueMicrotask")] + public static void QueueMicrotask(this IWindow window, Action callback) + { + window.SetTimeout(_ => callback?.Invoke(), 0); + } + /// /// Creates a new IHtmlImageElement instance. /// diff --git a/src/AngleSharp.Js/Extensions/DomDelegates.cs b/src/AngleSharp.Js/Extensions/DomDelegates.cs index 339d05b..fbcbee0 100644 --- a/src/AngleSharp.Js/Extensions/DomDelegates.cs +++ b/src/AngleSharp.Js/Extensions/DomDelegates.cs @@ -76,10 +76,15 @@ private static Func CreateFactory() var objExpr = Expression.Parameter(typeof(Function), "function"); var engineExpr = Expression.Parameter(typeof(EngineInstance), "engine"); + // The first parameter is the receiver of the call, the others are its arguments; a + // delegate without parameters calls with no receiver. Value types (e.g., the time of + // an animation frame) have to be boxed for the conversion. + Expression ToJsValue(ParameterExpression m) => + Expression.Call(convert, Expression.Convert(m, typeof(Object)), engineExpr); var call = Expression.Call(objExpr, "Call", new Type[0], new Expression[] { - Expression.Call(convert, parameters[0], engineExpr), - Expression.NewArrayInit(typeof(JsValue), parameters.Skip(1).Select(m => Expression.Call(convert, m, engineExpr)).ToArray()) + parameters.Length > 0 ? ToJsValue(parameters[0]) : Expression.Constant(JsValue.Undefined, typeof(JsValue)), + Expression.NewArrayInit(typeof(JsValue), parameters.Skip(1).Select(ToJsValue).ToArray()) }); var callback = Expression.Lambda(call, parameters); diff --git a/src/AngleSharp.Js/Extensions/EngineExtensions.cs b/src/AngleSharp.Js/Extensions/EngineExtensions.cs index a5d32f2..bc99e11 100644 --- a/src/AngleSharp.Js/Extensions/EngineExtensions.cs +++ b/src/AngleSharp.Js/Extensions/EngineExtensions.cs @@ -11,6 +11,7 @@ namespace AngleSharp.Js using Jint.Runtime.Descriptors; using Jint.Runtime.Interop; using System; + using System.Collections.Concurrent; using System.Collections.Generic; using System.Reflection; @@ -433,6 +434,49 @@ public static JsValue CallSameObject(MethodInfo method, JsValue thisObject) return instance.GetSameObject(target, method); } + /// + /// Gets the method to invoke on the given target. An interface may name a member it + /// re-declares explicitly - IHtmlCollection gives IReadOnlyList's indexer the name "item" + /// - and that slot has no implementation to invoke; the member of the interface it stands + /// for has. Likewise, objects sharing a prototype need not implement the same interfaces, + /// and invoking the member of an interface the target lacks fails. Either way the public + /// interface method of the same name and signature is taken from the target instead. + /// + private static MethodInfo BindToTarget(MethodInfo method, Object target) + { + var declaringType = method.DeclaringType; + + if (target == null || declaringType == null || !declaringType.GetTypeInfo().IsInterface || + (method.IsPublic && declaringType.IsInstanceOfType(target))) + { + return method; + } + + return _boundMethods.GetOrAdd((method, target.GetType()), key => + { + var name = key.Item1.Name; + var parameters = Array.ConvertAll(key.Item1.GetParameters(), m => m.ParameterType); + + // An explicit member is named after the interface it implements, e.g. + // System.Collections.Generic.IReadOnlyList.get_Item. + name = name.Substring(name.LastIndexOf('.') + 1); + + foreach (var type in key.Item2.GetInterfaces()) + { + var candidate = type.GetMethod(name, parameters); + + if (candidate != null && key.Item1.ReturnType.IsAssignableFrom(candidate.ReturnType)) + { + return candidate; + } + } + + return key.Item1; + }); + } + + private static readonly ConcurrentDictionary<(MethodInfo, Type), MethodInfo> _boundMethods = new ConcurrentDictionary<(MethodInfo, Type), MethodInfo>(); + public static JsValue Call(this EngineInstance instance, MethodInfo method, JsValue thisObject, JsValue[] arguments) { if (method != null) @@ -462,13 +506,17 @@ public static JsValue Call(this EngineInstance instance, MethodInfo method, JsVa } else { - var parameters = instance.BuildArgs(method, arguments); - return method.Invoke(nodeInstance.Value, parameters).ToJsValue(instance, description.ReturnType); + var target = nodeInstance.Value; + var bound = BindToTarget(method, target); + var parameters = instance.BuildArgs(bound, arguments); + return bound.Invoke(target, parameters).ToJsValue(instance, description.ReturnType); } } - catch (TargetInvocationException) + catch (TargetInvocationException ex) { - throw new JavaScriptException(instance.Jint.Intrinsics.Error); + // The message is all a script (or whoever reads its errors) gets to see of + // what went wrong, e.g. the DOMException of an invalid selector. + throw new JavaScriptException(instance.Jint.Intrinsics.Error, ex.InnerException?.Message); } } diff --git a/src/AngleSharp.Js/Extensions/Extensibility.cs b/src/AngleSharp.Js/Extensions/Extensibility.cs index 970c562..d0320d6 100644 --- a/src/AngleSharp.Js/Extensions/Extensibility.cs +++ b/src/AngleSharp.Js/Extensions/Extensibility.cs @@ -50,13 +50,13 @@ public static IDictionary GetExtensions(this IEnumerable entry.Adder = method; break; case Accessors.Method: - entry.Other = method; + entry.AddOther(method); break; } } else { - entry.Other = method; + entry.AddOther(method); } } } @@ -72,6 +72,18 @@ public class ExtensionEntry public MethodInfo Remover; public MethodInfo Other; public DomPutForwardsAttribute Forward; + + /// + /// Gets the overloads of the method, e.g. scrollTo(x, y) and scrollTo(options), in + /// the order of their declaration. Other is the first one. + /// + public List Overloads = new List(); + + public void AddOther(MethodInfo method) + { + Other = Other ?? method; + Overloads.Add(method); + } } } } diff --git a/src/AngleSharp.Js/Extensions/JsValueExtensions.cs b/src/AngleSharp.Js/Extensions/JsValueExtensions.cs index 2085451..a916958 100644 --- a/src/AngleSharp.Js/Extensions/JsValueExtensions.cs +++ b/src/AngleSharp.Js/Extensions/JsValueExtensions.cs @@ -4,6 +4,7 @@ namespace AngleSharp.Js using Jint; using Jint.Native; using Jint.Native.Function; + using Jint.Native.Object; using Jint.Runtime; using Jint.Runtime.Interop; using System.Linq; @@ -38,12 +39,59 @@ private static Object AsComplex(this JsValue value, Type targetType, EngineInsta } } + if (obj is ObjectInstance dictionary && TryCreateDictionary(dictionary, targetType, engine, out var created)) + { + return created; + } + var method = sourceType.PrepareConvert(targetType) ?? throw new JavaScriptException("[Internal] Could not find corresponding cast target."); return method.Invoke(obj, null); } + /// + /// Converts a plain object into a WebIDL dictionary, i.e. a class whose members carry a + /// [DomName] (e.g. { top: 100, behavior: 'smooth' } into ScrollToOptions). Members the + /// object lacks keep their default. + /// + private static Boolean TryCreateDictionary(ObjectInstance source, Type targetType, EngineInstance engine, out Object result) + { + var typeInfo = targetType.GetTypeInfo(); + result = null; + + if (!typeInfo.IsClass || typeInfo.IsAbstract || targetType == typeof(String) || targetType.GetConstructor(Type.EmptyTypes) == null) + { + return false; + } + + var members = targetType + .GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(m => m.SetMethod != null && m.SetMethod.IsPublic && m.GetIndexParameters().Length == 0) + .Select(m => new { Property = m, Name = m.GetCustomAttribute()?.OfficialName }) + .Where(m => m.Name != null) + .ToArray(); + + if (members.Length == 0) + { + return false; + } + + result = Activator.CreateInstance(targetType); + + foreach (var member in members) + { + var value = source.Get(member.Name); + + if (!value.IsUndefined()) + { + member.Property.SetValue(result, value.As(member.Property.PropertyType, engine)); + } + } + + return true; + } + public static Object FromJsValue(this JsValue val) { switch (val.Type) diff --git a/src/AngleSharp.Js/Extensions/OverloadResolution.cs b/src/AngleSharp.Js/Extensions/OverloadResolution.cs new file mode 100644 index 0000000..68c1de2 --- /dev/null +++ b/src/AngleSharp.Js/Extensions/OverloadResolution.cs @@ -0,0 +1,117 @@ +namespace AngleSharp.Js +{ + using Jint; + using Jint.Native; + using Jint.Native.Function; + using System; + using System.Reflection; + + /// + /// Picks one of several overloads of a DOM operation (e.g. scrollTo(x, y) and + /// scrollTo(options)) for the arguments of a call, in the spirit of WebIDL's overload + /// resolution: the number of arguments decides first, their types second. + /// + static class OverloadResolution + { + public static MethodInfo SelectOverload(this MethodInfo[] overloads, JsValue[] arguments) + { + var count = arguments.Length; + + // Trailing undefined arguments count as not passed. + while (count > 0 && arguments[count - 1].IsUndefined()) + { + count--; + } + + var best = overloads[0]; + var bestScore = Int32.MinValue; + + foreach (var overload in overloads) + { + var score = Score(overload, arguments, count); + + if (score > bestScore) + { + best = overload; + bestScore = score; + } + } + + return best; + } + + private static Int32 Score(MethodInfo method, JsValue[] arguments, Int32 count) + { + var parameters = method.GetParameters(); + // The receiver of an extension method is not an argument. + var offset = method.IsStatic && parameters.Length > 0 ? 1 : 0; + var available = parameters.Length - offset; + var required = 0; + + for (var i = offset; i < parameters.Length; i++) + { + if (!parameters[i].IsOptional && !parameters[i].IsDefined(typeof(ParamArrayAttribute), false)) + { + required++; + } + } + + if (count < required) + { + return -1000 + count - required; + } + + var score = count > available ? available - count : 0; + + for (var i = 0; i < Math.Min(count, available); i++) + { + score += Match(arguments[i], parameters[i + offset].ParameterType); + } + + return score; + } + + private static Int32 Match(JsValue value, Type type) + { + var target = Nullable.GetUnderlyingType(type) ?? type; + var info = target.GetTypeInfo(); + + if (value.IsNumber()) + { + return IsNumeric(target) ? 2 : target == typeof(Object) ? 1 : 0; + } + + if (value.IsBoolean()) + { + return target == typeof(Boolean) ? 2 : target == typeof(Object) ? 1 : 0; + } + + if (value.IsString()) + { + return target == typeof(String) ? 2 : info.IsEnum || target == typeof(Object) ? 1 : 0; + } + + if (value.IsNull() || value.IsUndefined()) + { + return !info.IsValueType || type != target ? 1 : 0; + } + + if (value is Function) + { + return info.IsSubclassOf(typeof(Delegate)) ? 2 : target == typeof(Object) ? 1 : 0; + } + + if (value is IDomProxy proxy) + { + return info.IsAssignableFrom(proxy.Value.GetType().GetTypeInfo()) ? 3 : 0; + } + + // A plain object fits a dictionary (e.g. ScrollToOptions), not a primitive. + return info.IsClass && target != typeof(String) ? 2 : 0; + } + + private static Boolean IsNumeric(Type type) => + type == typeof(Double) || type == typeof(Single) || type == typeof(Int32) || type == typeof(Int64) || + type == typeof(UInt32) || type == typeof(UInt16) || type == typeof(Int16) || type == typeof(Byte); + } +} diff --git a/src/AngleSharp.Js/Proxies/DomIndexers.cs b/src/AngleSharp.Js/Proxies/DomIndexers.cs index d60b184..cc7cd8b 100644 --- a/src/AngleSharp.Js/Proxies/DomIndexers.cs +++ b/src/AngleSharp.Js/Proxies/DomIndexers.cs @@ -24,14 +24,74 @@ sealed class DomIndexers private readonly Indexer _numeric; private readonly Indexer _namedGetter; private readonly Indexer _namedSetter; + private readonly Func _mapName; - public DomIndexers(Indexer numeric, Indexer namedGetter, Indexer namedSetter) + /// The numeric indexer, if any. + /// The getter of the string indexer, if any. + /// The setter of the string indexer, if any. + /// + /// Turns a property name into the name the string indexer takes, or null for a name it + /// does not support, e.g. the camel-cased names of a dataset. + /// + public DomIndexers(Indexer numeric, Indexer namedGetter, Indexer namedSetter, Func mapName = null) { _numeric = numeric; _namedGetter = namedGetter; _namedSetter = namedSetter; + _mapName = mapName; } + /// + /// The name of a dataset entry (HTML ยง3.2.6.6): every ASCII upper alpha of the + /// property name becomes a dash followed by its lowercase letter, e.g. sideNavVisible + /// is the attribute data-side-nav-visible. A dash followed by a lowercase letter has + /// no property name mapping to it. + /// + public static String ToDatasetName(String name) + { + var upper = 0; + + for (var i = 0; i < name.Length; i++) + { + var c = name[i]; + + if (c == '-' && i + 1 < name.Length && name[i + 1] >= 'a' && name[i + 1] <= 'z') + { + return null; + } + + if (c >= 'A' && c <= 'Z') + { + upper++; + } + } + + if (upper == 0) + { + return name; + } + + var chars = new Char[name.Length + upper]; + var j = 0; + + foreach (var c in name) + { + if (c >= 'A' && c <= 'Z') + { + chars[j++] = '-'; + chars[j++] = (Char)(c + 32); + } + else + { + chars[j++] = c; + } + } + + return new String(chars); + } + + private String MapName(String name) => _mapName != null ? _mapName(name) : name; + /// /// Asks the indexers about one property name, against the object being indexed rather /// than against the prototype declaring them. @@ -119,7 +179,14 @@ public Boolean TryGetFromNamedIndex(ObjectInstance prototype, Object value, JsVa return false; } - var valueAtIndex = _namedGetter.Invoke(value, new Object[] { index }); + var name = MapName(index); + + if (name == null) + { + return false; + } + + var valueAtIndex = _namedGetter.Invoke(value, new Object[] { name }); if (valueAtIndex == null && _namedSetter == null) { @@ -142,13 +209,14 @@ public Boolean TryGetFromNamedIndex(ObjectInstance prototype, Object value, JsVa /// hands out, which keeps its /// getter live rather than freezing the value it read. /// - public Object ReadNamed(Object value, Object[] args) => _namedGetter.Invoke(value, args); + public Object ReadNamed(Object value, Object[] args) => + _namedGetter.Invoke(value, _mapName != null ? new Object[] { MapName((String)args[0]) } : args); /// /// Writes one name through the string indexer. /// public void WriteNamed(Object value, Object[] args, EngineInstance engine) => - _namedSetter.Invoke(value, args, engine); + _namedSetter.Invoke(value, _mapName != null ? new Object[] { MapName((String)args[0]), args[1] } : args, engine); public Boolean TrySetToIndex(ObjectInstance prototype, EngineInstance engine, Object value, JsValue property, JsValue newValue) { @@ -164,7 +232,14 @@ public Boolean TrySetToIndex(ObjectInstance prototype, EngineInstance engine, Ob return false; } - _namedSetter.Invoke(value, new Object[] { index, newValue }, engine); + var name = MapName(index); + + if (name == null) + { + return false; + } + + _namedSetter.Invoke(value, new Object[] { name, newValue }, engine); return true; } diff --git a/src/AngleSharp.Js/Proxies/DomNodeInstance.cs b/src/AngleSharp.Js/Proxies/DomNodeInstance.cs index 970899d..f5da692 100644 --- a/src/AngleSharp.Js/Proxies/DomNodeInstance.cs +++ b/src/AngleSharp.Js/Proxies/DomNodeInstance.cs @@ -3,6 +3,7 @@ namespace AngleSharp.Js using AngleSharp.Dom; using Jint.Native; using Jint.Native.Object; + using Jint.Runtime; using Jint.Runtime.Descriptors; using System; using System.Collections.Generic; @@ -119,7 +120,14 @@ public override PropertyDescriptor GetOwnProperty(JsValue property) return State.CreateNamedDescriptor(_value, property.ToString()); } - return base.GetOwnProperty(property); + var descriptor = base.GetOwnProperty(property); + + if (ReferenceEquals(descriptor, PropertyDescriptor.Undefined)) + { + return GetGlobalProperty(property); + } + + return descriptor; } /// @@ -159,8 +167,18 @@ protected override Boolean TryGetOwnPropertyValue(JsValue property, JsValue rece if (ReferenceEquals(descriptor, PropertyDescriptor.Undefined)) { - value = JsValue.Undefined; - return false; + var global = GetGlobalProperty(property); + + if (ReferenceEquals(global, PropertyDescriptor.Undefined)) + { + value = JsValue.Undefined; + return false; + } + + value = global.Get == null + ? global.Value ?? JsValue.Undefined + : _instance.Jint.Call(global.Get, receiver, Arguments.Empty); + return true; } if (descriptor.Get == null) @@ -201,6 +219,11 @@ protected override OwnPropertyProbe ProbeOwnProperty(JsValue property) var descriptor = base.GetOwnProperty(property); + if (ReferenceEquals(descriptor, PropertyDescriptor.Undefined)) + { + descriptor = GetGlobalProperty(property); + } + if (ReferenceEquals(descriptor, PropertyDescriptor.Undefined)) { return OwnPropertyProbe.Missing; @@ -209,6 +232,45 @@ protected override OwnPropertyProbe ProbeOwnProperty(JsValue property) return descriptor.Enumerable ? OwnPropertyProbe.Enumerable : OwnPropertyProbe.NonEnumerable; } + /// + /// Gets the property of the global object a read of the window arrives at. + /// + /// + /// The window and the global object are two objects here, while in a browser they are + /// one. Whatever the window holds is mirrored onto the global object, but not the other + /// way around: a global a script declares (var x, or an assignment without declaration) + /// and the built-ins (Promise, JSON, ...) live on the global object only, so a read of + /// window.InstantClick or window.Promise has to be answered from there. It is asked + /// when the window itself has no such property, which also lets a global replacing a + /// member of the window's prototype (var item) win, as in a browser. + /// + private PropertyDescriptor GetGlobalProperty(JsValue property) + { + if (_value is IWindow) + { + var global = _instance.Jint.Global; + + if (!ReferenceEquals(global, this)) + { + return global.GetOwnProperty(property); + } + } + + return PropertyDescriptor.Undefined; + } + + public override Boolean Delete(JsValue property) + { + var deleted = base.Delete(property); + + if (deleted && _value is IWindow) + { + return _instance.Jint.Global.Delete(property); + } + + return deleted; + } + private DomIndexers.IndexerResult LookupIndex(JsValue property, out Object indexed) { var state = State; From f347249aa7b14b80f58877721790ca62ab787054 Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Fri, 30 Oct 2026 23:21:53 +0100 Subject: [PATCH 4/4] Removed ValueTuple --- .../Extensions/EngineExtensions.cs | 33 +++++++++++++++---- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/src/AngleSharp.Js/Extensions/EngineExtensions.cs b/src/AngleSharp.Js/Extensions/EngineExtensions.cs index bc99e11..15258a6 100644 --- a/src/AngleSharp.Js/Extensions/EngineExtensions.cs +++ b/src/AngleSharp.Js/Extensions/EngineExtensions.cs @@ -452,30 +452,49 @@ private static MethodInfo BindToTarget(MethodInfo method, Object target) return method; } - return _boundMethods.GetOrAdd((method, target.GetType()), key => + return _boundMethods.GetOrAdd(new BoundMethodKey(method, target.GetType()), static key => { - var name = key.Item1.Name; - var parameters = Array.ConvertAll(key.Item1.GetParameters(), m => m.ParameterType); + var name = key.Method.Name; + var parameters = Array.ConvertAll(key.Method.GetParameters(), m => m.ParameterType); // An explicit member is named after the interface it implements, e.g. // System.Collections.Generic.IReadOnlyList.get_Item. name = name.Substring(name.LastIndexOf('.') + 1); - foreach (var type in key.Item2.GetInterfaces()) + foreach (var type in key.TargetType.GetInterfaces()) { var candidate = type.GetMethod(name, parameters); - if (candidate != null && key.Item1.ReturnType.IsAssignableFrom(candidate.ReturnType)) + if (candidate != null && key.Method.ReturnType.IsAssignableFrom(candidate.ReturnType)) { return candidate; } } - return key.Item1; + return key.Method; }); } - private static readonly ConcurrentDictionary<(MethodInfo, Type), MethodInfo> _boundMethods = new ConcurrentDictionary<(MethodInfo, Type), MethodInfo>(); + private static readonly ConcurrentDictionary _boundMethods = new ConcurrentDictionary(); + + private readonly struct BoundMethodKey : IEquatable + { + public BoundMethodKey(MethodInfo method, Type targetType) + { + Method = method; + TargetType = targetType; + } + + public MethodInfo Method { get; } + + public Type TargetType { get; } + + public Boolean Equals(BoundMethodKey other) => Method == other.Method && TargetType == other.TargetType; + + public override Boolean Equals(Object obj) => obj is BoundMethodKey other && Equals(other); + + public override Int32 GetHashCode() => (Method.GetHashCode() * 397) ^ TargetType.GetHashCode(); + } public static JsValue Call(this EngineInstance instance, MethodInfo method, JsValue thisObject, JsValue[] arguments) {