diff --git a/CHANGELOG.md b/CHANGELOG.md index 09d0aed..87d88e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,19 @@ +# 1.1.3 + +Released on Friday, October 30 2026. + +- Fixed prepared regular-expression literals ignoring the timeout configured during engine creation +- Fixed the console only having `log`; it now has all methods of the console standard (e.g. `error`, `warn`, `info`, `debug`, `assert`, `count`, `time`), as scripts calling or wrapping one of them failed +- Fixed reading the globals of scripts (e.g. `var x`, `x = 1`) and the built-ins (e.g. `Promise`, `JSON`) through `window`, which gave `undefined` +- Fixed named indexers (e.g. `item` of `NodeList` and `HTMLCollection`, `namedItem`) being exposed as properties instead of methods +- Fixed functions passed for delegates with value-type or no parameters failing to convert +- Fixed errors thrown by DOM members losing their message +- Fixed only the last of several extension methods of the same name (e.g. `scrollTo(x, y)` and `scrollTo(options)`) being callable; the one fitting the arguments is called +- Fixed the geometry stubs of this library (e.g. `offsetHeight` always 0) hiding the implementation of another library (e.g. AngleSharp.Renderer) +- Fixed plain objects not converting to dictionaries (classes with `DomName` members, e.g. `{ top: 100 }` to `ScrollToOptions`) +- Fixed `dataset` rejecting camel-cased names (e.g. `dataset.sideNavVisible` for `data-side-nav-visible`) with a syntax error +- Added `requestAnimationFrame`, `cancelAnimationFrame`, `queueMicrotask`, and `performance` (`now()`, `timeOrigin`) to the window + # 1.1.2 Released on Sunday, October 25 2026. diff --git a/src/AngleSharp.Js.Tests/DomTests.cs b/src/AngleSharp.Js.Tests/DomTests.cs index 48c02b4..54b92f1 100644 --- a/src/AngleSharp.Js.Tests/DomTests.cs +++ b/src/AngleSharp.Js.Tests/DomTests.cs @@ -6,6 +6,17 @@ namespace AngleSharp.Js.Tests [TestFixture] public class DomTests { + [TestCase("(function () { var e = document.createElement('div'); e.setAttribute('data-side-nav-visible', 'true'); return e.dataset.sideNavVisible; })()", "true")] + [TestCase("(function () { var e = document.createElement('div'); e.dataset.sideNavVisible = 'false'; return e.getAttribute('data-side-nav-visible'); })()", "false")] + [TestCase("(function () { var e = document.createElement('div'); e.dataset.level = '2'; return e.getAttribute('data-level'); })()", "2")] + [TestCase("(function () { var e = document.createElement('div'); e.setAttribute('data-foo-bar', 'x'); return String(e.dataset['foo-bar']); })()", "undefined")] + public async Task DatasetMapsCamelCasedNamesToAttributes(string source, string expected) + { + // dev.to sets document.body.dataset.sideNavVisible, which used to throw a syntax error + var result = await source.EvalScriptAsync(); + Assert.AreEqual(expected, result); + } + [Test] public async Task NodeHasChildNodesIsAFunction() { 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.Tests/OverloadTests.cs b/src/AngleSharp.Js.Tests/OverloadTests.cs new file mode 100644 index 0000000..fc0fbce --- /dev/null +++ b/src/AngleSharp.Js.Tests/OverloadTests.cs @@ -0,0 +1,88 @@ +namespace AngleSharp.Js.Tests +{ + using AngleSharp.Attributes; + using AngleSharp.Dom; + using NUnit.Framework; + using System; + using System.Globalization; + using System.Threading.Tasks; + + [DomName("ProbeMode")] + public enum ProbeMode + { + [DomName("near")] + Near, + [DomName("far")] + Far, + } + + [DomName("ProbeOptions")] + public sealed class ProbeOptions + { + [DomName("left")] + public Double? Left { get; set; } + + [DomName("mode")] + public ProbeMode Mode { get; set; } = ProbeMode.Near; + } + + [DomExposed("Element")] + public static class ProbeElementExtensions + { + [DomName("probe")] + public static String Probe(this IElement element) => "none"; + + [DomName("offsetHeight")] + [DomAccessor(Accessors.Getter)] + public static Int32 GetOffsetHeight(this IElement element) => 42; + + [DomName("probe")] + public static String Probe(this IElement element, Double x, Double y) => + String.Format(CultureInfo.InvariantCulture, "xy:{0},{1}", x, y); + + [DomName("probe")] + public static String Probe(this IElement element, Boolean flag) => "flag:" + flag; + + [DomName("probe")] + public static String Probe(this IElement element, ProbeOptions options) => + String.Format(CultureInfo.InvariantCulture, "options:{0},{1}", options.Left?.ToString(CultureInfo.InvariantCulture) ?? "-", options.Mode); + } + + /// + /// Registered so that this assembly counts as a library the engine exposes. + /// + sealed class ProbeService + { + } + + /// + /// Several extension methods may share a name (scrollTo(x, y) and scrollTo(options)); the one + /// fitting the arguments is called, and a plain object becomes a dictionary. + /// + [TestFixture] + public class OverloadTests + { + [TestCase("probe()", "none")] + [TestCase("probe(1, 2)", "xy:1,2")] + [TestCase("probe(true)", "flag:True")] + [TestCase("probe({ left: 5, mode: 'far' })", "options:5,Far")] + [TestCase("probe({})", "options:-,Near")] + [TestCase("probe(undefined)", "none")] + public async Task OverloadFittingTheArgumentsIsCalled(String call, String expected) + { + var cfg = Configuration.Default.WithJs().With(new ProbeService()); + var document = await BrowsingContext.New(cfg).OpenAsync(m => m.Content("
")); + + Assert.AreEqual(expected, document.ExecuteScript($"document.getElementById('d').{call}")?.ToString()); + } + + [Test] + public async Task MemberOfALibraryWinsOverTheStubOfThisOne() + { + var cfg = Configuration.Default.WithJs().With(new ProbeService()); + var document = await BrowsingContext.New(cfg).OpenAsync(m => m.Content("
")); + + Assert.AreEqual("42", document.ExecuteScript("document.getElementById('d').offsetHeight")?.ToString()); + } + } +} diff --git a/src/AngleSharp.Js.Tests/WindowApiTests.cs b/src/AngleSharp.Js.Tests/WindowApiTests.cs new file mode 100644 index 0000000..e2d901c --- /dev/null +++ b/src/AngleSharp.Js.Tests/WindowApiTests.cs @@ -0,0 +1,138 @@ +namespace AngleSharp.Js.Tests +{ + using AngleSharp.Dom; + using AngleSharp.Js.Tests.Mocks; + using NUnit.Framework; + using System; + using System.Threading.Tasks; + + /// + /// The members of the window that real pages rely on - Honeybadger, e.g., reads + /// console.info.__hb_original, so a single missing console method stopped dev.to's base + /// bundle, and every script relying on its globals failed afterwards. + /// + [TestFixture] + public class WindowApiTests + { + [TestCase("error")] + [TestCase("warn")] + [TestCase("info")] + [TestCase("debug")] + [TestCase("trace")] + [TestCase("dir")] + [TestCase("table")] + [TestCase("group")] + [TestCase("groupEnd")] + [TestCase("assert")] + [TestCase("count")] + [TestCase("time")] + [TestCase("timeEnd")] + public async Task ConsoleHasMethod(String name) + { + var result = await $"typeof console.{name}".EvalScriptAsync(); + Assert.AreEqual("function", result); + } + + [Test] + public async Task ConsoleMethodsWriteToTheLogger() + { + var console = new ConsoleLogger(); + var cfg = Configuration.Default.WithJs().WithEventLoop().WithConsoleLogger(context => console); + var html = ""; + 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/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