From c5af7dbe9a28456828bbccbf31d3bf97a75ea534 Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Tue, 3 Nov 2026 13:03:53 +0100 Subject: [PATCH 01/13] Prepare for 1.2.0 --- AGENTS.md | 18 + CHANGELOG.md | 20 + src/AngleSharp.Js.Docs/package.json | 2 +- src/AngleSharp.Js.Tests/BindingTests.cs | 573 ++++++++++++++++++ src/AngleSharp.Js.Tests/CrossOriginTests.cs | 258 ++++++++ src/AngleSharp.Js.Tests/WorkerTests.cs | 30 + src/AngleSharp.Js/Cache/CreatorCache.cs | 157 ++++- src/AngleSharp.Js/Cache/DomShapeCache.cs | 51 +- src/AngleSharp.Js/Cache/MethodCache.cs | 21 + src/AngleSharp.Js/Dom/CrossOriginWindow.cs | 160 +++++ .../Dom/EventAttributeObserver.cs | 7 + src/AngleSharp.Js/Dom/OriginPolicy.cs | 75 +++ src/AngleSharp.Js/Dom/StructuredClone.cs | 76 +++ src/AngleSharp.Js/Dom/WindowExtensions.cs | 63 +- src/AngleSharp.Js/Dom/Worker.cs | 12 +- src/AngleSharp.Js/EngineInstance.cs | 42 +- .../Extensions/EngineExtensions.cs | 65 +- src/AngleSharp.Js/Extensions/Extensibility.cs | 8 + .../Extensions/JsValueExtensions.cs | 153 ++++- .../Extensions/OverloadResolution.cs | 35 +- src/AngleSharp.Js/JsScriptingService.cs | 8 + .../Proxies/DomConstructorInstance.cs | 43 +- src/Directory.Build.props | 2 +- 23 files changed, 1834 insertions(+), 45 deletions(-) create mode 100644 src/AngleSharp.Js.Tests/BindingTests.cs create mode 100644 src/AngleSharp.Js.Tests/CrossOriginTests.cs create mode 100644 src/AngleSharp.Js/Dom/CrossOriginWindow.cs create mode 100644 src/AngleSharp.Js/Dom/OriginPolicy.cs create mode 100644 src/AngleSharp.Js/Dom/StructuredClone.cs diff --git a/AGENTS.md b/AGENTS.md index 907e0729..d586d395 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -113,6 +113,24 @@ values. `EngineExtensions.BuildArgs` maps JS arguments onto a CLR signature and DOM-specific cases: an implicit leading `IWindow` parameter, optional parameters, `params` arrays, and `[DomInitDict]` option objects expanded into positional arguments. +What a type's members become, as libraries rely on it: + +- Instance members marked with `[DomName]` go on the prototype. Several methods or + `[DomConstructor]`s of the same name within one type are overloads, picked per call by + `OverloadResolution`; a method further up the type tree is hidden instead. +- Static members go on the constructor object (`DomConstructorInstance`, cached per type by + `CreatorCache.GetStaticMembers`). Only `[DomExposed]` *extension methods* are added to the type + they extend, so a class can be exposed to the window without spilling members onto it. +- A class overriding `ToString` gets a `toString` on its prototype, as such classes are exactly the + WebIDL stringifiers. A new override on a DOM type is hence visible to scripts. +- Into the CLR: `Object` receives bytes for a BufferSource and the raw Jint value for other objects. + Sequences are arrays and the generic interfaces arrays implement, filled from any JS iterable. A + class with `implicit operator`s from several types is a WebIDL union, picked like overloads; going + back out, its `implicit operator` to another type gives what a script receives for it. +- Out of the CLR: `[DomLiterals]` enums are strings (other enums are numbers, and exposed as an object + of constants), `Byte[]` is a new `ArrayBuffer`, `ArraySegment` a new `Uint8Array`, other arrays are JS arrays, every numeric type is a + number, `IDictionary` is a plain object, and a `Task` is a promise. + ## Performance **Performance is a primary concern in this repository, not an afterthought.** Jint is an diff --git a/CHANGELOG.md b/CHANGELOG.md index a6b02875..09bd53bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,23 @@ +# 1.2.0 + +Released on Friday, November 6 2026. + +- Fixed only the first of several methods or constructors of the same name being callable (e.g. `new URLSearchParams('a=1')`, `register(url, options)`); the one fitting the arguments is called +- Fixed static members not being reachable (e.g. `DOMRect.fromRect`); they are on the constructor and may take the window as first parameter +- Fixed members of classes marked with `DomExposed` ending up on what they are exposed to (e.g. the members of a constructor on `window`); only extension methods do +- Fixed arrays and other iterables not converting to sequences (arrays and the interfaces they implement) +- Fixed `ArrayBuffer`, typed arrays, and `DataView` not converting to `Byte[]`, `ArraySegment`, and `Object` (e.g. a request body being sent as `[object Uint8Array]`) +- Fixed numbers not converting from and to `Int64`, `UInt64`, `Int16`, `Byte`, `SByte`, `Single` (only from), and `Decimal`, as well as nullable forms of all value types +- Fixed values of `DomLiterals` enums being numbers instead of strings (e.g. `shadowRoot.mode`), and such enums being exposed as objects +- Fixed `Byte[]` being given as an array of numbers instead of an `ArrayBuffer` +- Fixed errors thrown by constructors losing their message +- Fixed WebIDL stringifiers (`URL`, `URLSearchParams`, `Location`, `Range`, `DOMTokenList`, `DOMMatrix`) answering `String(x)` with `[object X]`; a type overriding `ToString` has a `toString` +- Fixed sandboxed browsing contexts without `allow-scripts` running scripts and event handler attributes +- Fixed `postMessage` ignoring `targetOrigin`, giving it as the origin of the event, turning objects into strings, and not having a `source`; messages are copied by the structured clone algorithm, and the options form is supported +- Added `ArraySegment` being given as a `Uint8Array` +- Added classes with implicit conversions standing for a WebIDL union, e.g. of a string and a sequence of strings, which fixes such conversions failing; an implicit conversion from such a class is what a script receives for it +- Added the same-origin policy: script gets `null` for a node of another origin, and for its window a `CrossOriginWindow` with only `window`, `self`, `frames`, `parent`, `top`, `closed`, `length`, `location` (only to be set, and only for frames inside the own document), `postMessage`, `focus`, `blur` and `close` + # 1.1.4 Released on Sunday, November 1 2026. diff --git a/src/AngleSharp.Js.Docs/package.json b/src/AngleSharp.Js.Docs/package.json index c7093cd3..d4fe60b0 100644 --- a/src/AngleSharp.Js.Docs/package.json +++ b/src/AngleSharp.Js.Docs/package.json @@ -1,6 +1,6 @@ { "name": "@anglesharp/js", - "version": "1.1.4", + "version": "1.2.0", "preview": true, "description": "The doclet for the AngleSharp.Js documentation.", "keywords": [ diff --git a/src/AngleSharp.Js.Tests/BindingTests.cs b/src/AngleSharp.Js.Tests/BindingTests.cs new file mode 100644 index 00000000..a77ae8fb --- /dev/null +++ b/src/AngleSharp.Js.Tests/BindingTests.cs @@ -0,0 +1,573 @@ +namespace AngleSharp.Js.Tests +{ + using AngleSharp.Attributes; + using AngleSharp.Dom; + using NUnit.Framework; + using System; + using System.Collections.Generic; + using System.Globalization; + using System.Linq; + using System.Threading.Tasks; + + [DomLiterals] + [DomName("BindingColor")] + public enum BindingColor + { + [DomName("light-red")] + LightRed, + [DomName("blue")] + Blue, + } + + [DomName("BindingConstant")] + public enum BindingConstant + { + [DomName("FIRST")] + First = 1, + } + + public sealed class BindingUnion + { + private BindingUnion(String description) + { + Description = description; + } + + public String Description { get; } + + public static implicit operator BindingUnion(String value) => new BindingUnion("single:" + value); + + public static implicit operator BindingUnion(String[] values) => new BindingUnion("many:" + String.Join("|", values)); + + public static implicit operator BindingUnion(Double value) => new BindingUnion("number:" + value.ToString(CultureInfo.InvariantCulture)); + } + + public sealed class BindingGivenUnion + { + public String[] Values { get; set; } + + public static implicit operator BindingGivenUnion(String value) => new BindingGivenUnion { Values = new[] { value } }; + + public static implicit operator BindingGivenUnion(String[] values) => new BindingGivenUnion { Values = values }; + + public static implicit operator String[](BindingGivenUnion union) => union.Values; + } + + [DomName("BindingGivenOptions")] + public sealed class BindingGivenOptions + { + [DomName("urls")] + public BindingGivenUnion Urls { get; set; } + } + + [DomName("BindingStringifier")] + public class BindingStringifier + { + [DomConstructor] + public BindingStringifier() + { + } + + public override String ToString() => "stringified"; + } + + [DomName("BindingDerivedStringifier")] + public sealed class BindingDerivedStringifier : BindingStringifier + { + [DomConstructor] + public BindingDerivedStringifier() + { + } + } + + [DomName("BindingOptions")] + public sealed class BindingOptions + { + [DomName("union")] + public BindingUnion Union { get; set; } + + [DomName("unions")] + public Dictionary Unions { get; set; } + + [DomName("names")] + public String[] Names { get; set; } + + [DomName("children")] + public BindingOptions[] Children { get; set; } + + [DomName("groups")] + public Dictionary Groups { get; set; } + + [DomName("data")] + public Object Data { get; set; } + + [DomName("big")] + public UInt64? Big { get; set; } + + [DomName("flag")] + public Boolean? Flag { get; set; } + } + + [DomName("BindingBase")] + public class BindingBase + { + [DomName("inherited")] + public static String Inherited() => "base"; + + [DomName("hidden")] + public static String Hidden() => "base"; + + [DomName("hiddenInstance")] + public String HiddenInstance(Double value) => "base:" + value; + } + + [DomName("BindingProbe")] + [DomExposed("Window")] + public sealed class BindingProbe : BindingBase + { + private static String Stored = "initial"; + + [DomConstructor] + public BindingProbe() => Created = "none"; + + [DomConstructor] + public BindingProbe(String text) => Created = "text:" + text; + + [DomConstructor] + public BindingProbe(IWindow window, Double a, Double b) => Created = "window:" + (window != null) + ":" + (a + b); + + [DomConstructor] + public BindingProbe(BindingOptions options) => Created = "options:" + String.Join("|", options.Names ?? Array.Empty()); + + [DomName("created")] + public String Created { get; } + + [DomName("hidden")] + public static new String Hidden() => "derived"; + + [DomName("hiddenInstance")] + public new String HiddenInstance(Double value) => "derived:" + value; + + [DomName("constant")] + public static String Constant => "property"; + + [DomName("stored")] + public static String StoredValue + { + get => Stored; + set => Stored = value; + } + + [DomName("permission")] + [DomAccessor(Accessors.Getter)] + public static String GetPermission(IWindow window) => "window:" + (window != null); + + [DomName("request")] + public static Task Request(IWindow window, Boolean first = false) => + Task.FromResult(first && window != null ? BindingColor.LightRed : BindingColor.Blue); + + [DomName("noWindow")] + public static String NoWindow(String value) => "value:" + value; + + [DomName("noWindow")] + public static String NoWindow(Double a, Double b) => "sum:" + (a + b); + + [DomName("failing")] + public static String Failing() => throw new InvalidOperationException("static failed"); + + [DomName("overloaded")] + public String Overloaded() => "none"; + + [DomName("overloaded")] + public String Overloaded(BindingOptions options) => "options:" + (options?.Flag?.ToString() ?? "-"); + + [DomName("overloaded")] + public String Overloaded(String text) => "text:" + text; + + [DomName("overloaded")] + public String Overloaded(Byte[] bytes) => "bytes:" + String.Join(",", bytes); + + [DomName("overloaded")] + public String Overloaded(Double[] numbers) => "numbers:" + String.Join(",", numbers); + + [DomName("union")] + public String Union(BindingUnion value) => value?.Description ?? "null"; + + [DomName("unionInOptions")] + public String UnionInOptions(BindingOptions options) => options.Union?.Description + ";" + + String.Join("|", (options.Unions ?? new Dictionary()).Select(entry => entry.Key + "=" + entry.Value.Description)); + + [DomName("echoGiven")] + public BindingGivenOptions EchoGiven(BindingGivenOptions options) => options; + + [DomName("givenNothing")] + public BindingGivenUnion GivenNothing => null; + + [DomName("view")] + public ArraySegment View => new ArraySegment(new Byte[] { 1, 2, 3, 4 }, 1, 2); + + [DomName("emptyView")] + public ArraySegment EmptyView => default; + + [DomName("names")] + public String Names(String[] names) => String.Join("|", names); + + [DomName("sequence")] + public String Sequence(IEnumerable values) => String.Join("|", values); + + [DomName("options")] + public String Options(BindingOptions options) => String.Join(";", + String.Join("|", options.Names ?? Array.Empty()), + (options.Children ?? Array.Empty()).Length.ToString(CultureInfo.InvariantCulture), + String.Join("|", (options.Groups ?? new Dictionary()).Select(group => group.Key + "=" + String.Join(",", group.Value))), + options.Big?.ToString(CultureInfo.InvariantCulture) ?? "-", + options.Flag?.ToString() ?? "-"); + + [DomName("kindOfData")] + public String KindOfData(BindingOptions options) => options.Data?.GetType().Name ?? "null"; + + [DomName("bytes")] + public String Bytes(Byte[] bytes) => String.Join(",", bytes); + + [DomName("segment")] + public String Segment(ArraySegment bytes) => String.Join(",", bytes); + + [DomName("anything")] + public String Anything(Object value) => value switch + { + Byte[] bytes => "bytes:" + String.Join(",", bytes), + String text => "text:" + text, + null => "null", + _ => "other", + }; + + [DomName("numeric")] + public String Numeric(UInt64 a, Int64 b, Single c, Int16 d, Byte e, SByte f, Decimal g) => + String.Join("|", a, b, c.ToString(CultureInfo.InvariantCulture), d, e, f, g.ToString(CultureInfo.InvariantCulture)); + + [DomName("optionalNumber")] + public String OptionalNumber(UInt64? value) => value?.ToString(CultureInfo.InvariantCulture) ?? "none"; + + [DomName("color")] + public BindingColor Color(BindingColor value) => value; + + [DomName("constantValue")] + public BindingConstant ConstantValue => BindingConstant.First; + + [DomName("buffer")] + public Byte[] Buffer => new Byte[] { 1, 2, 3 }; + + [DomName("sharedBytes")] + public Byte[] SharedBytes { get; } = new Byte[] { 7 }; + + [DomName("big")] + public UInt64 Big => 5000000000UL; + + [DomName("long")] + public Int64 Long => -5000000000L; + + [DomName("short")] + public Int16 Short => -5; + + [DomName("byte")] + public Byte ByteValue => 200; + + [DomName("decimal")] + public Decimal DecimalValue => 1.5m; + } + + /// + /// How the attributes of a library end up on the objects of a script, and what a script can + /// pass to and get from its members. + /// + [TestFixture] + public class BindingTests + { + private static async Task OpenAsync() + { + var cfg = Configuration.Default.WithJs().With(new ProbeService()); + return await BrowsingContext.New(cfg).OpenAsync(m => m.Content("")); + } + + private static async Task Eval(String script) + { + var document = await OpenAsync(); + return document.ExecuteScript(script)?.ToString(); + } + + [TestCase("BindingProbe.constant", "property")] + [TestCase("BindingProbe.permission", "window:True")] + [TestCase("BindingProbe.noWindow('x')", "value:x")] + [TestCase("BindingProbe.noWindow(1, 2)", "sum:3")] + [TestCase("BindingProbe.inherited()", "base")] + [TestCase("BindingProbe.hidden()", "derived")] + [TestCase("BindingBase.hidden()", "base")] + [TestCase("typeof BindingProbe.request", "function")] + [TestCase("const f = BindingProbe.noWindow; f('detached')", "value:detached")] + public async Task StaticMembersAreOnTheConstructor(String script, String expected) + { + Assert.AreEqual(expected, await Eval(script)); + } + + [Test] + public async Task StaticMethodsGetTheWindowAndGivePromises() + { + var document = await OpenAsync(); + + document.ExecuteScript("window.seen = []; BindingProbe.request(true).then(v => seen.push(v)); BindingProbe.request().then(v => seen.push(v));"); + + for (var i = 0; i < 200 && document.ExecuteScript("seen.length")?.ToString() != "2"; i++) + { + await Task.Delay(10); + } + + Assert.AreEqual("light-red,blue", document.ExecuteScript("seen.join()")?.ToString()); + } + + [Test] + public async Task StaticPropertiesCanBeWritten() + { + Assert.AreEqual("changed", await Eval("BindingProbe.stored = 'changed'; const r = BindingProbe.stored; BindingProbe.stored = 'initial'; r")); + } + + [Test] + public async Task ErrorsOfStaticMembersKeepTheirMessage() + { + Assert.AreEqual("static failed", await Eval("try { BindingProbe.failing(); } catch (e) { e.message; }")); + } + + [TestCase("typeof new BindingProbe().noWindow")] + [TestCase("typeof new BindingProbe().constant")] + [TestCase("typeof new BindingProbe().permission")] + [TestCase("typeof window.noWindow")] + [TestCase("typeof window.permission")] + [TestCase("typeof window.overloaded")] + [TestCase("typeof window.created")] + public async Task MembersStayOnWhatTheyBelongTo(String script) + { + Assert.AreEqual("undefined", await Eval(script)); + } + + [Test] + public async Task ExposedTypesAreStillConstructors() + { + Assert.AreEqual("function", await Eval("typeof window.BindingProbe")); + } + + [TestCase("new BindingProbe().created", "none")] + [TestCase("new BindingProbe('a').created", "text:a")] + [TestCase("new BindingProbe(1, 2).created", "window:True:3")] + [TestCase("new BindingProbe({ names: ['x', 'y'] }).created", "options:x|y")] + public async Task ConstructorFittingTheArgumentsIsCalled(String script, String expected) + { + Assert.AreEqual(expected, await Eval(script)); + } + + [Test] + public async Task ConstructorOverloadsOfTheDomWork() + { + Assert.AreEqual("1", await Eval("new URLSearchParams('a=1').get('a')")); + Assert.AreEqual("False", await Eval("new URLSearchParams().has('a')")); + } + + [Test] + public async Task StaticMembersOfTheDomAreReachable() + { + // a fixture of the test assembly claims the name, so this assembly must not be a library here + var cfg = Configuration.Default.WithJs(); + var document = await BrowsingContext.New(cfg).OpenAsync(m => m.Content("")); + + Assert.AreEqual("3", document.ExecuteScript("DOMRect.fromRect({ x: 1, y: 2, width: 3, height: 4 }).width")?.ToString()); + Assert.AreEqual("undefined", document.ExecuteScript("typeof new DOMRect().fromRect")?.ToString()); + } + + [TestCase("CSS.escape('a b')", "a\\ b")] + [TestCase("CSS.supports('color', 'red')", "True")] + [TestCase("CSS.supports('(color: red)')", "True")] + public async Task NamespacesOfOtherLibrariesAreReachable(String script, String expected) + { + var document = await BrowsingContext.New(Helpers.GetCssConfig()).OpenAsync(m => m.Content("")); + + Assert.AreEqual(expected, document.ExecuteScript(script)?.ToString()); + } + + [TestCase("overloaded()", "none")] + [TestCase("overloaded('a')", "text:a")] + [TestCase("overloaded({ flag: true })", "options:True")] + [TestCase("overloaded(new Uint8Array([1, 2]))", "bytes:1,2")] + [TestCase("overloaded([1.5, 2])", "numbers:1.5,2")] + [TestCase("hiddenInstance(1)", "derived:1")] + public async Task MethodOverloadFittingTheArgumentsIsCalled(String call, String expected) + { + Assert.AreEqual(expected, await Eval($"new BindingProbe().{call}")); + } + + [TestCase("names(['a', 'b'])", "a|b")] + [TestCase("names([])", "")] + [TestCase("names(new Set(['a', 'b']))", "a|b")] + [TestCase("names([1, true])", "1|true")] + [TestCase("sequence([1, 2.9])", "1|2")] + [TestCase("options({ names: ['a'], children: [{}, {}], groups: { g: ['x', 'y'] }, big: 5000000000, flag: false })", "a;2;g=x,y;5000000000;False")] + [TestCase("options({})", ";0;;-;-")] + public async Task SequencesAreConverted(String call, String expected) + { + Assert.AreEqual(expected, await Eval($"new BindingProbe().{call}")); + } + + [TestCase("names({ length: 1 })")] + [TestCase("names('text')")] + [TestCase("names(1)")] + public async Task WhatIsNotASequenceIsRejected(String call) + { + Assert.AreEqual("thrown", await Eval($"try {{ new BindingProbe().{call}; 'passed'; }} catch (e) {{ 'thrown'; }}")); + } + + [TestCase("bytes(new Uint8Array([1, 2]))", "1,2")] + [TestCase("bytes(new Uint8Array([1, 2]).buffer)", "1,2")] + [TestCase("bytes(new Uint8Array([1, 2, 3, 4]).subarray(1, 3))", "2,3")] + [TestCase("bytes(new Uint16Array([513]))", "1,2")] + [TestCase("bytes(new DataView(new Uint8Array([1, 2, 3]).buffer, 1))", "2,3")] + [TestCase("bytes([1, 2])", "1,2")] + [TestCase("segment(new Uint8Array([1, 2]))", "1,2")] + [TestCase("anything(new Uint8Array([1, 2]).buffer)", "bytes:1,2")] + [TestCase("anything(new DataView(new Uint8Array([1]).buffer))", "bytes:1")] + [TestCase("anything('a')", "text:a")] + [TestCase("anything({})", "other")] + [TestCase("anything([1])", "other")] + [TestCase("kindOfData({ data: new Uint8Array(1) })", "Byte[]")] + [TestCase("kindOfData({ data: 'a' })", "String")] + public async Task BuffersAreConverted(String call, String expected) + { + Assert.AreEqual(expected, await Eval($"new BindingProbe().{call}")); + } + + [TestCase("numeric(5000000000, -5000000000, 1.5, -2, 200, -3, 2.5)", "5000000000|-5000000000|1.5|-2|200|-3|2.5")] + [TestCase("numeric(1.9, -1.9, 0, 0, 1.9, 0, 0)", "1|-1|0|0|1|0|0")] + [TestCase("numeric(NaN, Infinity, 0, NaN, NaN, NaN, NaN)", "0|0|0|0|0|0|0")] + [TestCase("numeric('7', '8', '1', '2', '3', '4', '5')", "7|8|1|2|3|4|5")] + [TestCase("numeric(0, 0, 0, 65537, 257, 129, 0)", "0|0|0|1|1|-127|0")] + [TestCase("optionalNumber(3)", "3")] + [TestCase("optionalNumber(undefined)", "none")] + [TestCase("optionalNumber(null)", "none")] + public async Task NumbersAreConverted(String call, String expected) + { + Assert.AreEqual(expected, await Eval($"new BindingProbe().{call}")); + } + + [TestCase("big", "5000000000")] + [TestCase("long", "-5000000000")] + [TestCase("short", "-5")] + [TestCase("byte", "200")] + [TestCase("decimal", "1.5")] + public async Task NumbersAreGivenAsNumbers(String member, String expected) + { + Assert.AreEqual(expected, await Eval($"new BindingProbe().{member}")); + Assert.AreEqual("number", await Eval($"typeof new BindingProbe().{member}")); + } + + [TestCase("union('a')", "single:a")] + [TestCase("union(['a', 'b'])", "many:a|b")] + [TestCase("union([])", "many:")] + [TestCase("union(new Set(['a']))", "many:a")] + [TestCase("union(1.5)", "number:1.5")] + [TestCase("union(null)", "null")] + [TestCase("unionInOptions({ union: 'a' })", "single:a;")] + [TestCase("unionInOptions({ unions: { x: 'a', y: ['b', 'c'] } })", ";x=single:a|y=many:b|c")] + public async Task ImplicitConversionsStandForUnions(String call, String expected) + { + Assert.AreEqual(expected, await Eval($"new BindingProbe().{call}")); + } + + [TestCase("Array.isArray(new BindingProbe().echoGiven({ urls: 'a' }).urls)", "True")] + [TestCase("new BindingProbe().echoGiven({ urls: 'a' }).urls.join()", "a")] + [TestCase("new BindingProbe().echoGiven({ urls: ['a', 'b'] }).urls.join()", "a,b")] + [TestCase("String(new BindingProbe().givenNothing)", "null")] + public async Task UnionsAreGivenThroughTheirImplicitConversion(String script, String expected) + { + Assert.AreEqual(expected, await Eval(script)); + } + + [Test] + public async Task ATypeOnlyConvertingAwayIsNoUnion() + { + Assert.AreEqual("https://a.example/", await Eval("new URL('https://a.example/').href")); + } + + [TestCase("new BindingProbe().view instanceof Uint8Array", "True")] + [TestCase("Array.from(new BindingProbe().view).join()", "2,3")] + [TestCase("new BindingProbe().view.buffer.byteLength", "2")] + [TestCase("new BindingProbe().emptyView.length", "0")] + public async Task ByteSegmentsAreGivenAsUint8Arrays(String script, String expected) + { + Assert.AreEqual(expected, await Eval(script)); + } + + [TestCase("String(new BindingStringifier())", "stringified")] + [TestCase("new BindingStringifier() + ''", "stringified")] + [TestCase("`${new BindingStringifier()}`", "stringified")] + [TestCase("new BindingStringifier().toString()", "stringified")] + [TestCase("String(new BindingDerivedStringifier())", "stringified")] + [TestCase("Object.prototype.toString.call(new BindingStringifier())", "[object BindingStringifier]")] + [TestCase("String(new BindingProbe())", "[object BindingProbe]")] + [TestCase("BindingStringifier.prototype.hasOwnProperty('toString')", "True")] + public async Task OverridingToStringMakesAStringifier(String script, String expected) + { + Assert.AreEqual(expected, await Eval(script)); + } + + [TestCase("String(new URLSearchParams('a=1&b=2'))", "a=1&b=2")] + [TestCase("new URLSearchParams('a=1').toString()", "a=1")] + [TestCase("new URLSearchParams().toString()", "")] + [TestCase("String(new URL('https://a.example/b?c#d'))", "https://a.example/b?c#d")] + [TestCase("document.body.classList.add('x', 'y'); String(document.body.classList)", "x y")] + [TestCase("const r = document.createRange(); r.selectNodeContents(document.getElementById('t')); String(r)", "text")] + [TestCase("String(new DOMMatrix())", "matrix(1, 0, 0, 1, 0, 0)")] + [TestCase("String(location)", "https://page.example/path")] + [TestCase("location + ''", "https://page.example/path")] + [TestCase("String(document.createElement('div'))", "[object HTMLDivElement]")] + [TestCase("String(document)", "[object HTMLDocument]")] + public async Task StringifiersOfTheDomGiveTheirText(String script, String expected) + { + var cfg = Configuration.Default.WithJs(); + var document = await BrowsingContext.New(cfg).OpenAsync(m => m.Address("https://page.example/path").Content("
text
")); + + Assert.AreEqual(expected, document.ExecuteScript(script)?.ToString()); + } + + [TestCase("color('blue')", "blue")] + [TestCase("color('light-red')", "light-red")] + public async Task LiteralEnumsAreStrings(String call, String expected) + { + Assert.AreEqual(expected, await Eval($"new BindingProbe().{call}")); + Assert.AreEqual("string", await Eval($"typeof new BindingProbe().{call}")); + } + + [Test] + public async Task LiteralEnumsHaveNoObjectWhileConstantsKeepTheirs() + { + Assert.AreEqual("undefined", await Eval("typeof BindingColor")); + Assert.AreEqual("1", await Eval("BindingConstant.FIRST")); + Assert.AreEqual("number", await Eval("typeof new BindingProbe().constantValue")); + } + + [Test] + public async Task LiteralEnumsOfTheDomAreStrings() + { + Assert.AreEqual("open", await Eval("document.createElement('div').attachShadow({ mode: 'open' }).mode")); + } + + [Test] + public async Task BytesAreGivenAsArrayBuffers() + { + Assert.AreEqual("True", await Eval("new BindingProbe().buffer instanceof ArrayBuffer")); + Assert.AreEqual("2", await Eval("new Uint8Array(new BindingProbe().buffer)[1]")); + Assert.AreEqual("3", await Eval("new BindingProbe().buffer.byteLength")); + } + + [Test] + public async Task ChangingAGivenBufferDoesNotChangeTheObjectOwningIt() + { + Assert.AreEqual("7", await Eval("const p = new BindingProbe(); new Uint8Array(p.sharedBytes)[0] = 9; new Uint8Array(p.sharedBytes)[0]")); + } + } +} diff --git a/src/AngleSharp.Js.Tests/CrossOriginTests.cs b/src/AngleSharp.Js.Tests/CrossOriginTests.cs new file mode 100644 index 00000000..14a09809 --- /dev/null +++ b/src/AngleSharp.Js.Tests/CrossOriginTests.cs @@ -0,0 +1,258 @@ +namespace AngleSharp.Js.Tests +{ + using AngleSharp.Browser; + using AngleSharp.Dom; + using AngleSharp.Html.Dom; + using AngleSharp.Io; + using NUnit.Framework; + using System; + using System.Collections.Generic; + using System.IO; + using System.Text; + using System.Threading; + using System.Threading.Tasks; + + [TestFixture] + public class CrossOriginTests + { + private const String PageAddress = "https://a.test/page.html"; + private const String CrossAddress = "https://b.test/frame.html"; + private const String SameAddress = "https://a.test/frame.html"; + + private sealed class SiteRequester : BaseRequester + { + private readonly Dictionary _pages; + + public SiteRequester(Dictionary pages) => _pages = pages; + + public override Boolean SupportsProtocol(String protocol) => true; + + protected override async Task PerformRequestAsync(Request request, CancellationToken cancel) + { + // the element is set up by the time this arrives: Core before 1.9.2 can lose a frame otherwise + await Task.Delay(30, cancel).ConfigureAwait(false); + _pages.TryGetValue(request.Address.Href, out var html); + return new DefaultResponse + { + Address = request.Address, + StatusCode = html is null ? System.Net.HttpStatusCode.NotFound : System.Net.HttpStatusCode.OK, + Content = new MemoryStream(Encoding.UTF8.GetBytes(html ?? String.Empty)), + Headers = new Dictionary { ["Content-Type"] = "text/html" }, + }; + } + } + + private static async Task<(IDocument Page, IDocument Frame)> LoadAsync(String frameAddress, String frameHtml, String pageScript = "") + { + var pages = new Dictionary + { + [PageAddress] = $"", + [frameAddress] = frameHtml, + }; + var config = Configuration.Default + .With(new SiteRequester(pages)) + .WithDefaultLoader(new LoaderOptions { IsResourceLoadingEnabled = true }) + .WithJs() + .WithEventLoop(); + var page = await BrowsingContext.New(config).OpenAsync(PageAddress); + var frame = (IHtmlInlineFrameElement)page.GetElementById("f"); + await Until(() => frame.ContentDocument?.ReadyState == DocumentReadyState.Complete); + return (page, frame.ContentDocument); + } + + private static async Task Until(Func condition) + { + for (var i = 0; i < 200 && !condition(); i++) + { + await Task.Delay(20); + } + + Assert.IsTrue(condition(), "The condition never held."); + } + + private static String Eval(IDocument document, String script) => Convert.ToString(document.ExecuteScript(script), System.Globalization.CultureInfo.InvariantCulture); + + [Test] + public async Task ContentDocumentOfAnotherOriginIsNull() + { + var (page, _) = await LoadAsync(CrossAddress, "secret"); + Assert.AreEqual("true", Eval(page, "String(document.getElementById('f').contentDocument === null)")); + } + + [Test] + public async Task ContentDocumentOfTheSameOriginIsAvailable() + { + var (page, _) = await LoadAsync(SameAddress, "mine"); + Assert.AreEqual("mine", Eval(page, "document.getElementById('f').contentDocument.title")); + Assert.AreEqual("mine", Eval(page, "document.getElementById('f').contentWindow.document.title")); + } + + [TestCase("document")] + [TestCase("localStorage")] + [TestCase("name")] + [TestCase("navigator")] + [TestCase("fetch")] + [TestCase("setTimeout")] + [TestCase("getComputedStyle")] + [TestCase("eval")] + [TestCase("secret")] + public async Task WindowOfAnotherOriginHasNoSuchMember(String member) + { + var (page, _) = await LoadAsync(CrossAddress, ""); + Assert.AreEqual("undefined", Eval(page, $"typeof document.getElementById('f').contentWindow.{member}")); + } + + [TestCase("postMessage", "function")] + [TestCase("closed", "boolean")] + [TestCase("length", "number")] + [TestCase("location", "object")] + [TestCase("parent", "object")] + [TestCase("top", "object")] + public async Task WindowOfAnotherOriginHasWhatIsAllowed(String member, String type) + { + var (page, _) = await LoadAsync(CrossAddress, ""); + Assert.AreEqual(type, Eval(page, $"typeof document.getElementById('f').contentWindow.{member}")); + } + + [Test] + public async Task WindowOfAnotherOriginStaysTheSameObject() + { + var (page, _) = await LoadAsync(CrossAddress, ""); + Assert.AreEqual("true", Eval(page, "String(document.getElementById('f').contentWindow === document.getElementById('f').contentWindow)")); + } + + [Test] + public async Task LocationOfAnotherOriginCannotBeRead() + { + var (page, _) = await LoadAsync(CrossAddress, ""); + Assert.AreEqual("thrown", Eval(page, "(function () { try { return document.getElementById('f').contentWindow.location.href; } catch (e) { return 'thrown'; } })()")); + } + + [Test] + public async Task FrameCannotReachThePageOfAnotherOrigin() + { + var (_, frame) = await LoadAsync(CrossAddress, ""); + + Assert.AreEqual("undefined", Eval(frame, "typeof parent.document")); + Assert.AreEqual("undefined", Eval(frame, "typeof top.document")); + Assert.AreEqual("undefined", Eval(frame, "typeof parent.parent.document")); + Assert.AreEqual("true", Eval(frame, "String(parent === top)")); + } + + [Test] + public async Task FrameCanReachThePageOfTheSameOrigin() + { + var (_, frame) = await LoadAsync(SameAddress, ""); + Assert.AreEqual("object", Eval(frame, "typeof parent.document")); + } + + [Test] + public async Task FrameCannotNavigateThePageOfAnotherOrigin() + { + var (page, frame) = await LoadAsync(CrossAddress, ""); + + Assert.AreEqual("thrown", Eval(frame, "(function () { try { top.location.href = 'https://evil.test/'; return 'set'; } catch (e) { return 'thrown'; } })()")); + Assert.AreEqual(PageAddress, page.Url); + } + + [Test] + public async Task PageCanNavigateItsFrameOfAnotherOrigin() + { + var (page, frame) = await LoadAsync(CrossAddress, ""); + Assert.AreEqual("set", Eval(page, "(function () { try { document.getElementById('f').contentWindow.location.href = 'https://c.test/'; return 'set'; } catch (e) { return 'thrown'; } })()")); + } + + private const String EchoingFrame = ""; + + [Test] + public async Task MessageReachesAFrameOfAnotherOriginWithWhoSentIt() + { + var (page, frame) = await LoadAsync(CrossAddress, EchoingFrame, "window.addEventListener('message', function (e) { window.reply = e.data; window.replyOrigin = e.origin; });"); + + Eval(page, "document.getElementById('f').contentWindow.postMessage({ a: 1, b: [2, 'x'], c: { d: true } }, 'https://b.test')"); + + await Until(() => Eval(frame, "window.seen") != "undefined" && Eval(page, "window.reply") == "back"); + Assert.AreEqual("{\"a\":1,\"b\":[2,\"x\"],\"c\":{\"d\":true}}", Eval(frame, "window.seen")); + Assert.AreEqual("https://a.test", Eval(frame, "window.seenOrigin")); + Assert.AreEqual("https://b.test", Eval(page, "window.replyOrigin")); + } + + [TestCase("https://c.test")] + [TestCase("https://a.test")] + [TestCase("/")] + public async Task MessageForAnotherOriginIsNotDelivered(String targetOrigin) + { + var (page, frame) = await LoadAsync(CrossAddress, EchoingFrame); + + Eval(page, $"document.getElementById('f').contentWindow.postMessage('x', '{targetOrigin}')"); + await Task.Delay(100); + + Assert.AreEqual("undefined", Eval(frame, "typeof window.seen")); + } + + [Test] + public async Task MessageWithOptionsHasItsTargetOrigin() + { + var (page, frame) = await LoadAsync(CrossAddress, EchoingFrame); + + Eval(page, "document.getElementById('f').contentWindow.postMessage('x', { targetOrigin: 'https://b.test' })"); + + await Until(() => Eval(frame, "typeof window.seen") == "string"); + } + + [TestCase("function () {}")] + [TestCase("document")] + [TestCase("{ a: document.body }")] + public async Task WhatCannotBeClonedIsNotPosted(String message) + { + var (page, frame) = await LoadAsync(CrossAddress, EchoingFrame); + + Assert.AreEqual("thrown", Eval(page, $"(function () {{ try {{ document.getElementById('f').contentWindow.postMessage({message}, '*'); return 'posted'; }} catch (e) {{ return 'thrown'; }} }})()")); + } + + [Test] + public async Task CyclicObjectIsNotPosted() + { + var (page, _) = await LoadAsync(CrossAddress, EchoingFrame); + + Assert.AreEqual("thrown", Eval(page, "(function () { var a = {}; a.self = a; try { document.getElementById('f').contentWindow.postMessage(a, '*'); return 'posted'; } catch (e) { return 'thrown'; } })()")); + } + + [Test] + public async Task MessageIsACopy() + { + var (page, frame) = await LoadAsync(SameAddress, ""); + + Eval(page, "window.sent = { a: 1 }; document.getElementById('f').contentWindow.postMessage(window.sent, '*')"); + await Until(() => Eval(frame, "typeof window.got") == "object"); + + Assert.AreEqual("1", Eval(page, "window.sent.a")); + } + + [Test] + public async Task SandboxWithoutScriptsRunsNothing() + { + var config = Configuration.Default.WithJs().WithEventLoop(); + var top = BrowsingContext.New(config); + var context = top.CreateChild("sandboxed", Sandboxes.Scripts); + var document = await context.OpenAsync(m => m.Content("")); + + document.Body.DoClick(); + + await Task.Delay(50); + Assert.AreNotEqual("ran", document.Title); + Assert.AreNotEqual("clicked", document.Title); + } + + [Test] + public async Task SandboxWithoutSameOriginIsOfNoOrigin() + { + var config = Configuration.Default.WithJs().WithEventLoop(); + var top = await BrowsingContext.New(config).OpenAsync(m => m.Content("").Address(PageAddress)); + var context = top.Context.CreateChild("sandboxed", Sandboxes.Origin); + var document = await context.OpenAsync(m => m.Content("").Address(PageAddress)); + + Assert.AreEqual("undefined", Eval(document, "typeof parent.document")); + } + } +} diff --git a/src/AngleSharp.Js.Tests/WorkerTests.cs b/src/AngleSharp.Js.Tests/WorkerTests.cs index 63a93220..98cfdfea 100644 --- a/src/AngleSharp.Js.Tests/WorkerTests.cs +++ b/src/AngleSharp.Js.Tests/WorkerTests.cs @@ -18,6 +18,36 @@ public async Task WorkerConstructorShouldExistOnWindow() Assert.AreEqual("function", result); } + [Test] + public async Task WorkerReceivesBytesAsArrayBuffer() + { + var config = Configuration.Default + .WithJs() + .WithEventLoop() + .With(new DelayedRequester(0, "self.onmessage = function (event) { self.__kind = event.data instanceof ArrayBuffer ? new Uint8Array(event.data)[0] : 'other'; };")) + .WithDefaultLoader(new LoaderOptions { IsResourceLoadingEnabled = true }); + + var context = BrowsingContext.New(config); + var document = await context.OpenAsync(request => request.Content("
")).ConfigureAwait(false); + var worker = new AngleSharp.Js.Dom.Worker(document.DefaultView, "https://example.com/worker.js"); + var retries = 200; + + while (retries-- > 0 && !worker.IsInitialized && worker.StartupError == null) + { + await Task.Delay(10).ConfigureAwait(false); + } + + worker.PostMessage(new Byte[] { 42 }); + retries = 200; + + while (retries-- > 0 && worker.EvaluateInWorker("String(self.__kind)")?.ToString() == "undefined") + { + await Task.Delay(10).ConfigureAwait(false); + } + + Assert.AreEqual("42", worker.EvaluateInWorker("String(self.__kind)")?.ToString()); + } + [Test] public async Task WorkerCanBeConstructedFromClr() { diff --git a/src/AngleSharp.Js/Cache/CreatorCache.cs b/src/AngleSharp.Js/Cache/CreatorCache.cs index 445bed31..1635fc7d 100644 --- a/src/AngleSharp.Js/Cache/CreatorCache.cs +++ b/src/AngleSharp.Js/Cache/CreatorCache.cs @@ -8,6 +8,8 @@ using System.Collections.Generic; using System.Linq; using System.Reflection; +using System.Runtime.CompilerServices; +using AngleSharp.Dom; namespace AngleSharp.Js.Cache { @@ -16,6 +18,119 @@ static class CreatorCache private static readonly ConcurrentDictionary _constructorDefinitions = new(); private static readonly ConcurrentDictionary _enumLiteralDefinitions = new(); private static readonly ConcurrentDictionary> _nonEnumTypeNames = new(); + private static readonly ConcurrentDictionary _staticMembers = new(); + private static readonly ConcurrentDictionary _literalNames = new(); + private static readonly ConcurrentDictionary _implicitConversions = new(); + + /// + /// Gets the implicit conversion back from a union, i.e. from a type that also converts + /// from other types, as an object without DOM members would be of no use to a script. + /// A type only converting away (Url to Uri) is no union and remains what it is. + /// + public static MethodInfo GetGivenConversion(this Type type) + { + if (type == typeof(void) || type == typeof(String) || !type.GetTypeInfo().IsClass || type.GetImplicitConversions().Length == 0) + { + return null; + } + + return type.GetMethods(BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly) + .FirstOrDefault(m => m.IsSpecialName && m.Name == "op_Implicit" && m.ReturnType != type && m.GetParameters()[0].ParameterType == type); + } + + /// + /// Gets the implicit conversions a type declares to itself, in the order of declaration. + /// + public static MethodInfo[] GetImplicitConversions(this Type type) => _implicitConversions.GetOrAdd(type, static target => + target.GetMethods(BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly) + .Where(m => m.IsSpecialName && m.Name == "op_Implicit" && m.ReturnType == target) + .ToArray()); + + /// + /// Gets the string script sees for a value of a WebIDL enumeration, or null if the enum + /// is a set of constants, which stay numbers. + /// + public static String GetLiteralName(this Enum value) => _literalNames.GetOrAdd(value, static member => + { + var type = member.GetType(); + var isLiteral = type == typeof(DocumentReadyState) || type.GetTypeInfo().GetCustomAttribute() != null; + + return isLiteral + ? type.GetTypeInfo().GetDeclaredField(member.ToString())?.GetCustomAttribute()?.OfficialName + : null; + }); + + /// + /// Gets the static members a type exposes on its constructor, the ones of base classes + /// included. A derived class hides a member of the same name, as a shadowing property + /// of a constructor would. + /// + public static StaticMember[] GetStaticMembers(this Type type) => _staticMembers.GetOrAdd(type, static target => + { + var members = new Dictionary(StringComparer.Ordinal); + var flags = BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly; + + for (var current = target; current != null; current = current.GetTypeInfo().BaseType) + { + var found = new Dictionary(StringComparer.Ordinal); + + foreach (var method in current.GetMethods(flags)) + { + // Extending the window is how a namespace such as CSS gets its window in C#; + // anything else extended is no member of this type. + if (method.IsSpecialName || (method.IsDefined(typeof(ExtensionAttribute), false) && !MethodDescription.Of(method).TakesWindow)) + { + continue; + } + + var accessor = method.GetCustomAttribute()?.Type; + + foreach (var attribute in method.GetCustomAttributes()) + { + if (!found.TryGetValue(attribute.OfficialName, out var member)) + { + member = new StaticMember(attribute.OfficialName); + found.Add(attribute.OfficialName, member); + } + + if (accessor == Accessors.Getter) + { + member.Getter = method; + } + else if (accessor == Accessors.Setter) + { + member.Setter = method; + } + else + { + member.Add(method); + } + } + } + + foreach (var property in current.GetProperties(flags)) + { + foreach (var attribute in property.GetCustomAttributes()) + { + found[attribute.OfficialName] = new StaticMember(attribute.OfficialName) + { + Getter = property.GetMethod, + Setter = property.SetMethod, + }; + } + } + + foreach (var entry in found) + { + if (!members.ContainsKey(entry.Key)) + { + members.Add(entry.Key, entry.Value); + } + } + } + + return members.Values.ToArray(); + }); /// /// Gets what is needed to build the constructor object for a type, or null if the @@ -35,8 +150,8 @@ public static ConstructorDefinition GetConstructorDefinition(this Type type) if (names.Length > 0 && !ti.IsEnum) { - var info = ti.DeclaredConstructors.FirstOrDefault(m => m.GetCustomAttributes().Any()); - definition = new ConstructorDefinition(type, names, info); + var overloads = ti.DeclaredConstructors.Where(m => m.GetCustomAttributes().Any()).ToArray(); + definition = new ConstructorDefinition(type, names, overloads); } _constructorDefinitions.TryAdd(type, definition); @@ -55,7 +170,8 @@ public static EnumLiteralDefinition GetEnumLiteralDefinition(this Type type) { var ti = type.GetTypeInfo(); - if (ti.IsEnum) + // A WebIDL enumeration is a set of strings without an object of its own. + if (ti.IsEnum && ti.GetCustomAttribute() == null) { var name = ti.GetCustomAttributes(true).FirstOrDefault()?.OfficialName; @@ -219,11 +335,11 @@ public EnumLiteralMember(String name, Object value) /// sealed class ConstructorDefinition { - public ConstructorDefinition(Type type, String[] names, ConstructorInfo info) + public ConstructorDefinition(Type type, String[] names, ConstructorInfo[] overloads) { Type = type; Names = names; - Info = info; + Overloads = overloads; } /// @@ -242,9 +358,34 @@ public ConstructorDefinition(Type type, String[] names, ConstructorInfo info) public String Name => Names[0]; /// - /// Gets the constructor to invoke, or null if the type cannot be constructed from - /// script - naming it is still legal, calling it is not. + /// Gets the constructors to pick from, which are none if the type cannot be constructed + /// from script - naming it is still legal, calling it is not. /// - public ConstructorInfo Info { get; } + public ConstructorInfo[] Overloads { get; } + } + + /// + /// Describes a static member of a DOM type, which belongs on the constructor object. + /// + sealed class StaticMember + { + private readonly List _overloads = new List(); + + public StaticMember(String name) + { + Name = name; + } + + public String Name { get; } + + public MethodInfo Getter { get; set; } + + public MethodInfo Setter { get; set; } + + public Boolean IsAccessor => Getter != null || Setter != null; + + public MethodInfo[] Overloads => _overloads.ToArray(); + + public void Add(MethodInfo method) => _overloads.Add(method); } } diff --git a/src/AngleSharp.Js/Cache/DomShapeCache.cs b/src/AngleSharp.Js/Cache/DomShapeCache.cs index fc229da9..4d9a466a 100644 --- a/src/AngleSharp.Js/Cache/DomShapeCache.cs +++ b/src/AngleSharp.Js/Cache/DomShapeCache.cs @@ -106,6 +106,8 @@ public ShapeKey(Type type, LibrarySet libs) sealed class DomShapeBuilder { private const String ConstructorName = "constructor"; + private const String ToStringName = "toString"; + private const String ClrToStringName = "ToString"; private readonly Type _type; private readonly Type _baseType; @@ -115,6 +117,7 @@ sealed class DomShapeBuilder private readonly Boolean _perRealmMethods; private readonly List _order; private readonly Dictionary _members; + private readonly Dictionary _methodOwners; private Indexer _numericIndexer; private Indexer _stringIndexerGetter; @@ -129,6 +132,7 @@ public DomShapeBuilder(Type type, LibrarySet libs, HashSet reserved) _reserved = reserved; _order = new List(); _members = new Dictionary(StringComparer.Ordinal); + _methodOwners = new Dictionary(StringComparer.Ordinal); // The window's members are the one set a script reaches without naming a receiver: // they are copied onto the global object, and a bare "alert('hi')" calls with no @@ -141,6 +145,7 @@ public DomShape Build() { SetAllMembers(_type); SetExtensionMembers(); + SetStringifier(); var constructor = _type.GetConstructorDefinition(_libs.Assemblies); var builder = new JsObjectShape.Builder(); @@ -209,6 +214,24 @@ private void SetExtensionMembers() } } + /// + /// A WebIDL stringifier (URL, Location, DOMTokenList, ...) has no attribute to find it by, + /// because its C# counterpart is overriding ToString - which only such types do. Without + /// it the type would answer with its tag, as in String(new URLSearchParams('a=1')). + /// + private void SetStringifier() + { + if (_name != null && !_members.ContainsKey(ToStringName) && _type.GetTypeInfo().IsClass) + { + var method = _type.GetMethod(ClrToStringName, Type.EmptyTypes); + + if (method != null && method.DeclaringType != typeof(Object)) + { + Define(ToStringName, new MethodMember(method)); + } + } + } + private void SetAllMembers(Type parentType) { foreach (var type in parentType.GetTypeTree()) @@ -264,6 +287,12 @@ private void SetNormalProperties(IEnumerable properties) { foreach (var property in properties) { + // A static member belongs to the constructor, see DomConstructorInstance. + if ((property.GetMethod ?? property.SetMethod).IsStatic) + { + continue; + } + var indexParameters = property.GetIndexParameters(); var accessor = property.GetCustomAttribute()?.Type; var putsForward = property.GetCustomAttribute(); @@ -324,6 +353,12 @@ private void SetNormalMethods(IEnumerable methods) { foreach (var method in methods) { + // A static member belongs to the constructor, see DomConstructorInstance. + if (method.IsStatic) + { + continue; + } + foreach (var m in method.GetCustomAttributes()) { SetMethod(m.OfficialName, method); @@ -370,13 +405,17 @@ private void SetIndexer(PropertyInfo property, ParameterInfo[] indexParameters) private void SetMethod(String name, MethodInfo method) { - //TODO Jint - // If it already has a property with the given name (usually another method), - // then convert that method to a two-layer method, which decides which one - // to pick depending on the number (and probably types) of arguments. if (!IsDeclared(name)) { Define(name, new MethodMember(method)); + _methodOwners[name] = method.DeclaringType; + } + else if (_members.TryGetValue(name, out var member) && member is MethodMember existing && + _methodOwners.TryGetValue(name, out var owner) && owner == method.DeclaringType) + { + // Only within one type: a method of the same name further up the tree is hidden + // by the one found first, it does not become an overload of it. + existing.Add(method); } } @@ -406,13 +445,15 @@ private abstract class Member private sealed class MethodMember : Member { - private readonly MethodInfo[] _overloads; + private MethodInfo[] _overloads; public MethodMember(params MethodInfo[] overloads) { _overloads = overloads; } + public void Add(MethodInfo method) => _overloads = _overloads.Concat(new[] { method }).ToArray(); + public override void Declare(JsObjectShape.Builder builder, String name, Boolean perRealm) { if (_overloads.Length > 1) diff --git a/src/AngleSharp.Js/Cache/MethodCache.cs b/src/AngleSharp.Js/Cache/MethodCache.cs index 9a9be6e5..c364be25 100644 --- a/src/AngleSharp.Js/Cache/MethodCache.cs +++ b/src/AngleSharp.Js/Cache/MethodCache.cs @@ -5,6 +5,7 @@ namespace AngleSharp.Js.Cache using System; using System.Collections.Concurrent; using System.Reflection; + using System.Runtime.CompilerServices; /// /// Describes what building the arguments of a method needs to know about it. @@ -30,10 +31,18 @@ private MethodDescription(MethodBase method) InitDict = method.GetCustomAttribute(); ReturnType = method.GetCustomAttribute()?.ReturnType; TakesWindow = parameters.Length > 0 && parameters[0].ParameterType == typeof(IWindow); + GivenConversion = (method as MethodInfo)?.ReturnType.GetGivenConversion(); + HasReceiver = TakesWindow || (method.IsStatic && method.IsDefined(typeof(ExtensionAttribute), false)); TakesParamArray = parameters.Length > 0 && parameters[parameters.Length - 1].GetCustomAttribute() != null; } + /// + /// Gets the value a script should receive for what the method returned. + /// + public Object ToGiven(Object value) => + GivenConversion == null || value == null ? value : GivenConversion.Invoke(null, new[] { value }); + public static MethodDescription Of(MethodBase method) => _descriptions.GetOrAdd(method, m => new MethodDescription(m)); @@ -45,6 +54,18 @@ public static MethodDescription Of(MethodBase method) => public Boolean TakesWindow { get; } + /// + /// Gets if the first parameter is not an argument of the call, i.e., it is the object + /// an extension method extends or the window that is provided implicitly. + /// + public Boolean HasReceiver { get; } + + /// + /// Gets how the value of a WebIDL union is given to a script, if the method returns one. + /// Resolved here so that all other members only pay for looking at a null. + /// + public MethodInfo GivenConversion { get; } + public Boolean TakesParamArray { get; } } diff --git a/src/AngleSharp.Js/Dom/CrossOriginWindow.cs b/src/AngleSharp.Js/Dom/CrossOriginWindow.cs new file mode 100644 index 00000000..ff835715 --- /dev/null +++ b/src/AngleSharp.Js/Dom/CrossOriginWindow.cs @@ -0,0 +1,160 @@ +namespace AngleSharp.Js.Dom +{ + using AngleSharp.Attributes; + using AngleSharp.Dom; + using AngleSharp.Html.Dom; + using System; + using System.Linq; + + /// + /// What script sees of a window of another origin (HTML 7.2.1.3.1): only the members listed + /// here exist, so that whatever else a window has or gets stays out of reach. + /// + [DomName("CrossOriginWindow")] + [DomNoInterfaceObject] + public sealed class CrossOriginWindow + { + private readonly IWindow _target; + private readonly IDocument _viewer; + private readonly CrossOriginLocation _location; + + internal CrossOriginWindow(IWindow target, IDocument viewer) + { + _target = target; + _viewer = viewer; + _location = new CrossOriginLocation(target, viewer); + } + + /// + /// Gets the window itself, as seen from the other origin. + /// + [DomName("window")] + [DomName("self")] + [DomName("frames")] + public IWindow Self => _target; + + /// + /// Gets the window around it, which the engine turns into what the viewer may see of it. + /// + [DomName("parent")] + public IWindow Parent => _target.Parent(); + + /// + /// Gets the top window, which the engine turns into what the viewer may see of it. + /// + [DomName("top")] + public IWindow Top => _target.Top(); + + /// + /// Gets if the window is closed. + /// + [DomName("closed")] + public Boolean IsClosed => _target.IsClosed; + + /// + /// Gets the number of frames in the window. + /// + [DomName("length")] + public Int32 Length => _target.Document.QuerySelectorAll("iframe").Length; + + /// + /// Gets the location, of which only navigating is available. + /// + [DomName("location")] + [DomPutForwards("href")] + public CrossOriginLocation Location => _location; + + /// + /// Posts a message to the window. + /// + [DomName("postMessage")] + public void PostMessage(Object message, Object targetOrigin = null, Object transfer = null) => + WindowExtensions.PostMessageFrom(_viewer, _target, message, targetOrigin); + + /// + /// Focuses the window. + /// + [DomName("focus")] + public void Focus() => _target.Focus(); + + /// + /// Removes the focus from the window. + /// + [DomName("blur")] + public void Blur() => _target.Blur(); + + /// + /// Does nothing: only the window itself and who opened it close it. + /// + [DomName("close")] + public void Close() + { + } + } + + /// + /// What script sees of the location of a window of another origin: it can be set, not read. + /// + [DomName("CrossOriginLocation")] + [DomNoInterfaceObject] + public sealed class CrossOriginLocation + { + private readonly IWindow _target; + private readonly IDocument _viewer; + + internal CrossOriginLocation(IWindow target, IDocument viewer) + { + _target = target; + _viewer = viewer; + } + + /// + /// Navigates the window, which script may only do for frames inside its own document. + /// Reading it throws, as the address of another origin is none of its business. + /// + [DomName("href")] + public String Href + { + get => throw new DomException(DomError.Security); + set => Navigate(value); + } + + /// + /// Navigates the window, see . + /// + [DomName("replace")] + public void Replace(String url) => Navigate(url); + + /// + /// Navigates the window, see . + /// + [DomName("assign")] + public void Assign(String url) => Navigate(url); + + private void Navigate(String url) + { + // a frame taking over what is around it (or something next to it) is what framing + // protections are about: browsers only let it happen after the user did something, + // which isn't known here + if (!IsInsideViewer()) + { + throw new DomException(DomError.Security); + } + + _target.Document.Location.Href = new Url(new Url(_viewer.BaseUri ?? String.Empty), url).Href; + } + + private Boolean IsInsideViewer() + { + for (var context = _target.Document.Context?.Parent; context is not null; context = context.Parent) + { + if (ReferenceEquals(context, _viewer.Context)) + { + return true; + } + } + + return false; + } + } +} diff --git a/src/AngleSharp.Js/Dom/EventAttributeObserver.cs b/src/AngleSharp.Js/Dom/EventAttributeObserver.cs index 55928c4d..444e7277 100644 --- a/src/AngleSharp.Js/Dom/EventAttributeObserver.cs +++ b/src/AngleSharp.Js/Dom/EventAttributeObserver.cs @@ -96,6 +96,13 @@ private void RegisterEventCallback(String eventName) if (sender is TElement element) { var document = element.Owner; + + // a sandboxed frame runs nothing of its document, handlers included + if (OriginPolicy.IsScriptingBlocked(document)) + { + return; + } + var engine = _service.GetOrCreateInstance(document); var jint = engine.Jint; var instance = jint.Intrinsics.Function.Construct(new JsValue[] { "event", value }, JsValue.Undefined); diff --git a/src/AngleSharp.Js/Dom/OriginPolicy.cs b/src/AngleSharp.Js/Dom/OriginPolicy.cs new file mode 100644 index 00000000..5e9ebef9 --- /dev/null +++ b/src/AngleSharp.Js/Dom/OriginPolicy.cs @@ -0,0 +1,75 @@ +namespace AngleSharp.Js.Dom +{ + using AngleSharp.Browser; + using AngleSharp.Dom; + using System; + + /// + /// The same-origin policy (HTML 7.1). A document in a browsing context of its own is only + /// reachable by script of the same origin. Everything crossing between two documents passes + /// through , so that is where it is enforced, + /// whichever member gave it out (contentWindow, parent, event.source, ...). + /// + static class OriginPolicy + { + /// + /// Whether the document is out of reach for script of the viewing one. A document nobody + /// navigated to (e.g. of DOMParser) has no origin of its own to protect. + /// + public static Boolean IsCrossOrigin(IDocument viewer, IDocument document) + { + if (ReferenceEquals(viewer, document) || document?.Context is null || ReferenceEquals(viewer.Context, document.Context) || + !ReferenceEquals(document.Context.Active, document)) + { + return false; + } + + return !Equals(GetOriginKey(viewer), GetOriginKey(document)); + } + + /// + /// The origin as sent with a message: "null" for an opaque one. + /// + public static String Serialize(IDocument document) => GetOriginKey(document) as String ?? "null"; + + /// + /// What identifies the origin: its serialization, or for an opaque origin (sandboxed, + /// data:, file:, ...) the document having it, which equals nothing else. A document + /// without an address of its own (about:blank, about:srcdoc) has the origin of its creator. + /// + private static Object GetOriginKey(IDocument document) + { + for (var current = document; current is not null; current = current.Context?.Parent?.Active) + { + var context = current.Context; + + if (context is not null && (context.Security & Sandboxes.Origin) == Sandboxes.Origin) + { + return current; + } + + var url = new Url(current.Url ?? String.Empty); + + if (!url.Scheme.Equals("about", StringComparison.OrdinalIgnoreCase) && url.Href.Length > 0) + { + return url.Scheme.Equals("file", StringComparison.OrdinalIgnoreCase) || String.IsNullOrEmpty(url.Origin) + ? current + : url.Origin; + } + + if (context?.Parent?.Active is null) + { + return current; + } + } + + return document; + } + + /// + /// Whether the context is not allowed to run script (the `sandbox` of an iframe). + /// + public static Boolean IsScriptingBlocked(IDocument document) => + document?.Context is { } context && (context.Security & Sandboxes.Scripts) == Sandboxes.Scripts; + } +} diff --git a/src/AngleSharp.Js/Dom/StructuredClone.cs b/src/AngleSharp.Js/Dom/StructuredClone.cs new file mode 100644 index 00000000..f39afff7 --- /dev/null +++ b/src/AngleSharp.Js/Dom/StructuredClone.cs @@ -0,0 +1,76 @@ +namespace AngleSharp.Js.Dom +{ + using AngleSharp.Dom; + using Jint; + using Jint.Native; + using Jint.Native.Array; + using Jint.Native.Function; + using Jint.Native.Object; + using System; + using System.Collections.Generic; + + /// + /// A copy of a value for another realm (HTML 2.7), which holds nothing of the one it comes + /// from: a script object of one engine can't be used by another one, and handing it over would + /// hand over that engine, too. Functions and DOM objects can't be cloned. + /// + static class StructuredClone + { + private const Int32 MaxDepth = 256; + + public static Object Clone(Object value) => Clone(value, new HashSet(), 0); + + private static Object Clone(Object value, HashSet path, Int32 depth) + { + switch (value) + { + case null: + case String: + case Boolean: + case Double: + case Int32: + return value; + case JsValue js when js is not ObjectInstance: + return js.IsUndefined() || js.IsNull() ? null : Clone(js.ToObject(), path, depth); + case ObjectInstance source when source is not IDomProxy && source is not Function && depth < MaxDepth && path.Add(source): + var result = CloneObject(source, path, depth); + path.Remove(source); + return result; + default: + throw new DomException(DomError.DataClone); + } + } + + private static Object CloneObject(ObjectInstance source, HashSet path, Int32 depth) + { + if (source is JsDate date) + { + return date.ToObject(); + } + + if (source is ArrayInstance array) + { + var items = new Object[(Int32)array.Get("length").AsNumber()]; + + for (var i = 0; i < items.Length; i++) + { + items[i] = Clone(array.Get(i), path, depth + 1); + } + + return items; + } + + var record = new Dictionary(); + + foreach (var key in source.GetOwnPropertyKeys()) + { + if (key.IsString() && source.GetOwnProperty(key).Enumerable) + { + record[key.AsString()] = Clone(source.Get(key), path, depth + 1); + } + } + + return record; + } + } +} diff --git a/src/AngleSharp.Js/Dom/WindowExtensions.cs b/src/AngleSharp.Js/Dom/WindowExtensions.cs index 22eedc30..9a243585 100644 --- a/src/AngleSharp.Js/Dom/WindowExtensions.cs +++ b/src/AngleSharp.Js/Dom/WindowExtensions.cs @@ -7,6 +7,7 @@ namespace AngleSharp.Js.Dom using AngleSharp.Dom.Events; using AngleSharp.Html.Dom; using AngleSharp.Js.Attributes; + using Jint; using System; using System.Runtime.CompilerServices; @@ -27,9 +28,69 @@ public static class WindowExtensions private const Int32 FrameInterval = 16; /// - /// Posts a message. + /// Posts a message to the window (HTML 9.4.3), from the document of the script calling. /// [DomName("postMessage")] + public static void PostMessage(this IWindow window, Object message, Object targetOrigin = null, Object transfer = null) + { + if (WorkerOwners.TryGetValue(window, out var owner)) + { + owner.PostMessageToOwner(message); + return; + } + + PostMessageFrom(EngineInstance.Current?.Window.Value is IWindow sender ? sender.Document : window.Document, window, message, targetOrigin); + } + + internal static void PostMessageFrom(IDocument sender, IWindow target, Object message, Object targetOrigin) + { + var origin = ResolveTargetOrigin(sender, targetOrigin); + var data = StructuredClone.Clone(message); + + // who it is for may have moved on; then it isn't delivered + if (origin != "*" && origin != OriginPolicy.Serialize(target.Document)) + { + return; + } + + var ev = new MessageEvent("message", false, false, data, OriginPolicy.Serialize(sender), String.Empty, sender.DefaultView); + target.Document.Context.GetService().EnqueueAsync(_ => target.Fire(ev)); + } + + // given as a string, or in the options + private static String ResolveTargetOrigin(IDocument sender, Object targetOrigin) + { + if (targetOrigin is Jint.Native.Object.ObjectInstance options) + { + var value = options.Get("targetOrigin"); + targetOrigin = value.IsString() ? value.AsString() : null; + } + + var origin = targetOrigin as String ?? "/"; + + if (origin == "*") + { + return origin; + } + + if (origin == "/") + { + return OriginPolicy.Serialize(sender); + } + + var url = new Url(origin); + + if (url.IsInvalid || String.IsNullOrEmpty(url.Scheme)) + { + throw new DomException(DomError.Syntax); + } + + return url.Origin ?? "null"; + } + + /// + /// Posts a text message. + /// public static void PostMessage(this IWindow window, String message, String targetOrigin = "*", Object transfer = null) { if (WorkerOwners.TryGetValue(window, out var owner)) diff --git a/src/AngleSharp.Js/Dom/Worker.cs b/src/AngleSharp.Js/Dom/Worker.cs index 854a31ad..c3c4a584 100644 --- a/src/AngleSharp.Js/Dom/Worker.cs +++ b/src/AngleSharp.Js/Dom/Worker.cs @@ -227,7 +227,17 @@ private void InitializeWorker(Url sourceUrl) private void DeliverToWorker(Object message) { var engine = _scripting.GetOrCreateJint(_workerDocument); - engine.SetValue(WorkerIncomingMessageName, message); + + // An array would reach the other engine as a wrapper of the CLR object. + if (message is Byte[] bytes) + { + engine.SetValue(WorkerIncomingMessageName, engine.Intrinsics.ArrayBuffer.Construct(bytes)); + } + else + { + engine.SetValue(WorkerIncomingMessageName, message); + } + _scripting.EvaluateScript(_workerDocument, WorkerDispatchScript, MimeTypeNames.DefaultJavaScript, _workerDocument.Url); } diff --git a/src/AngleSharp.Js/EngineInstance.cs b/src/AngleSharp.Js/EngineInstance.cs index d1b44fb3..dba2a6f2 100644 --- a/src/AngleSharp.Js/EngineInstance.cs +++ b/src/AngleSharp.Js/EngineInstance.cs @@ -35,6 +35,7 @@ sealed class EngineInstance private readonly ConditionalWeakTable _sameObjects; private readonly LibrarySet _libs; private readonly DomNodeInstance _window; + private readonly IDocument _document; private readonly JsImportMap _importMap; private readonly List> _unhandledRejections = new List>(); @@ -67,6 +68,8 @@ public EngineInstance(IWindow window, IDictionary assignments, I _engine.SetValue(assignment.Key, assignment.Value); } + _self = new WeakReference(this); + _document = window.Document; _window = (DomNodeInstance)GetDomNode(window); foreach (var lib in libs) @@ -98,6 +101,26 @@ public EngineInstance(IWindow window, IDictionary assignments, I public DomNodeInstance Window => _window; + /// + /// The engine of the script that called into the DOM last on this thread. + /// + public static EngineInstance Current + { + get => s_current != null && s_current.TryGetTarget(out var engine) ? engine : null; + set => s_current = value?._self; + } + + // weak, as an idle thread must not keep the engine of a closed page alive + [ThreadStatic] + private static WeakReference s_current; + private readonly WeakReference _self; + + /// + /// Whether the node is of a document of another origin, which script here must not get. + /// + public Boolean IsOutOfReach(Object obj) => + obj is INode node && OriginPolicy.IsCrossOrigin(_document, node as IDocument ?? node.Owner); + public Engine Jint => _engine; public JsImportMap ImportMap => _importMap; @@ -254,7 +277,8 @@ private static Type GetResultType(Type type) public ObjectInstance GetDomNode(Object obj) => _references.GetOrCreate(obj, CreateInstance); - public ObjectInstance GetDomNode(Object obj, Type type) => CreateInstance(obj, type); + public ObjectInstance GetDomNode(Object obj, Type type) => + obj is IWindow || IsOutOfReach(obj) ? GetDomNode(obj) : CreateInstance(obj, type); public ObjectInstance GetDomPrototype(Type type) => _prototypes.GetOrCreate(type, CreatePrototype); @@ -276,6 +300,11 @@ public JsValue GetSameObject(Object owner, MethodInfo getter) // Nodes and windows already have a canonical proxy. Preserve its identity, // concrete prototype and script-owned properties across SameObject getters. + if (IsOutOfReach(current)) + { + return JsValue.Null; + } + if (current is INode || current is IWindow) { return GetDomNode(current); @@ -416,7 +445,16 @@ private static PropertyDescriptor AsGlobalProperty(PropertyDescriptor descriptor // A collection is projected by the array-like proxy, which lets the engine read its // indices and length directly; everything else by the ordinary one. The decision is a // property of the type, so it is resolved once per type for the whole process. - private ObjectInstance CreateInstance(Object obj) => CreateInstance(obj, obj.GetType()); + private ObjectInstance CreateInstance(Object obj) + { + // what got here from another origin is all the same denied, whoever asked for it + if (obj is IWindow window && OriginPolicy.IsCrossOrigin(_document, window.Document)) + { + return CreateInstance(new CrossOriginWindow(window, _document), typeof(CrossOriginWindow)); + } + + return IsOutOfReach(obj) ? new Jint.Native.JsObject(_engine) : CreateInstance(obj, obj.GetType()); + } private ObjectInstance CreateInstance(Object obj, Type type) { diff --git a/src/AngleSharp.Js/Extensions/EngineExtensions.cs b/src/AngleSharp.Js/Extensions/EngineExtensions.cs index e70be5e8..332b0b7f 100644 --- a/src/AngleSharp.Js/Extensions/EngineExtensions.cs +++ b/src/AngleSharp.Js/Extensions/EngineExtensions.cs @@ -26,6 +26,10 @@ public static JsValue ToJsValue(this Object obj, EngineInstance engine) { return value; } + else if (engine.IsOutOfReach(obj)) + { + return JsValue.Null; + } else if (obj is String) { return JsValue.FromObjectWithType(engine.Jint, obj, typeof(String)); @@ -50,20 +54,38 @@ public static JsValue ToJsValue(this Object obj, EngineInstance engine) { return JsValue.FromObjectWithType(engine.Jint, obj, typeof(Boolean)); } - else if (obj is Enum) + else if (obj is Enum literal) { - switch (obj) + var name = literal.GetLiteralName(); + + if (name != null) { - case DocumentReadyState _: - var name = ((Enum)obj).GetOfficialName(); - if (name != null) - { - return JsValue.FromObjectWithType(engine.Jint, name, typeof(String)); - } - break; + return JsValue.FromObjectWithType(engine.Jint, name, typeof(String)); } + return JsValue.FromObjectWithType(engine.Jint, obj, typeof(Enum)); } + else if (obj is Int64 || obj is UInt64 || obj is Int16 || obj is UInt16 || obj is Byte || obj is SByte || obj is Decimal) + { + return JsNumber.Create(Convert.ToDouble(obj, System.Globalization.CultureInfo.InvariantCulture)); + } + else if (obj is Byte[] bytes) + { + // What WebIDL calls an ArrayBuffer; a copy as the object owning the array keeps it. + return engine.Jint.Intrinsics.ArrayBuffer.Construct((Byte[])bytes.Clone()); + } + else if (obj is ArraySegment segment) + { + // What WebIDL calls a Uint8Array, i.e. a view rather than the buffer itself. + var view = new Byte[segment.Count]; + + if (segment.Count > 0) + { + Buffer.BlockCopy(segment.Array, segment.Offset, view, 0, segment.Count); + } + + return engine.Jint.Intrinsics.Uint8Array.Construct(engine.Jint.Intrinsics.ArrayBuffer.Construct(view), null, null); + } else if (obj is Task task) { return engine.CreatePromise(task); @@ -104,7 +126,7 @@ public static JsValue ToJsValue(this Object obj, EngineInstance engine, Type typ { if (obj != null && type != null && type.IsDomType()) { - return engine.GetDomNode(obj, type); + return engine.IsOutOfReach(obj) ? JsValue.Null : engine.GetDomNode(obj, type); } return obj.ToJsValue(engine); @@ -530,10 +552,29 @@ public BoundMethodKey(MethodInfo method, Type targetType) public override Int32 GetHashCode() => (Method.GetHashCode() * 397) ^ TargetType.GetHashCode(); } + /// + /// Calls a static member of a DOM type on behalf of its constructor object. There is no + /// receiver to pass on, unlike for the extension methods handled by Call. + /// + public static JsValue CallStatic(this EngineInstance instance, MethodInfo method, JsValue[] arguments) + { + try + { + var parameters = instance.BuildArgs(method, arguments); + var description = MethodDescription.Of(method); + return description.ToGiven(method.Invoke(null, parameters)).ToJsValue(instance, description.ReturnType); + } + catch (TargetInvocationException ex) + { + throw new JavaScriptException(instance.Jint.Intrinsics.Error, ex.InnerException?.Message); + } + } + public static JsValue Call(this EngineInstance instance, MethodInfo method, JsValue thisObject, JsValue[] arguments) { if (method != null) { + EngineInstance.Current = instance; IDomProxy nodeInstance; if (thisObject.Type == Types.Object && thisObject.AsObject() is IDomProxy node) @@ -555,14 +596,14 @@ public static JsValue Call(this EngineInstance instance, MethodInfo method, JsVa newArgs[0] = (JsValue)nodeInstance; Array.Copy(arguments, 0, newArgs, 1, arguments.Length); var parameters = instance.BuildArgs(method, newArgs); - return method.Invoke(null, parameters).ToJsValue(instance, description.ReturnType); + return description.ToGiven(method.Invoke(null, parameters)).ToJsValue(instance, description.ReturnType); } else { var target = nodeInstance.Value; var bound = BindToTarget(method, target); var parameters = instance.BuildArgs(bound, arguments); - return bound.Invoke(target, parameters).ToJsValue(instance, description.ReturnType); + return description.ToGiven(bound.Invoke(target, parameters)).ToJsValue(instance, description.ReturnType); } } catch (TargetInvocationException ex) diff --git a/src/AngleSharp.Js/Extensions/Extensibility.cs b/src/AngleSharp.Js/Extensions/Extensibility.cs index d0320d6f..59db1d88 100644 --- a/src/AngleSharp.Js/Extensions/Extensibility.cs +++ b/src/AngleSharp.Js/Extensions/Extensibility.cs @@ -5,6 +5,7 @@ namespace AngleSharp.Js using System.Collections.Generic; using System.Linq; using System.Reflection; + using System.Runtime.CompilerServices; static class Extensibility { @@ -14,6 +15,13 @@ public static IDictionary GetExtensions(this IEnumerable foreach (var method in methods) { + // [DomExposed] is also found on classes with members of their own (a constructor + // exposed to the window), which must not end up on what they are exposed to. + if (!method.IsDefined(typeof(ExtensionAttribute), false)) + { + continue; + } + var names = method.GetCustomAttributes() .Select(m => m.OfficialName); var accessors = method.GetCustomAttributes() diff --git a/src/AngleSharp.Js/Extensions/JsValueExtensions.cs b/src/AngleSharp.Js/Extensions/JsValueExtensions.cs index ef17d128..f99a020a 100644 --- a/src/AngleSharp.Js/Extensions/JsValueExtensions.cs +++ b/src/AngleSharp.Js/Extensions/JsValueExtensions.cs @@ -1,25 +1,152 @@ namespace AngleSharp.Js { using AngleSharp.Attributes; + using AngleSharp.Js.Cache; using Jint; using Jint.Native; using Jint.Native.Function; using Jint.Native.Object; + using Jint.Native.Symbol; using Jint.Runtime; using Jint.Runtime.Interop; using System.Linq; using System; using System.Collections; using System.Collections.Generic; + using System.Globalization; using System.Reflection; static class JsValueExtensions { + // The one kind of buffer Jint has no public type for. + private static readonly Type DataViewType = typeof(Engine).GetTypeInfo().Assembly.GetType("Jint.Native.JsDataView"); + + /// + /// Checks for a WebIDL BufferSource, i.e. an ArrayBuffer or a view on one. + /// + public static Boolean IsBufferSource(this JsValue value) => + value is JsArrayBuffer || value is JsTypedArray || (value is ObjectInstance && value.GetType() == DataViewType); + + /// + /// Gets the element type if the type stands for a WebIDL sequence, otherwise null. + /// + public static Type GetSequenceElementType(this Type type) + { + if (type.IsArray) + { + return type.GetArrayRank() == 1 ? type.GetElementType() : null; + } + + var typeInfo = type.GetTypeInfo(); + + if (typeInfo.IsInterface && typeInfo.IsGenericType && typeInfo.GenericTypeArguments.Length == 1) + { + var elementType = typeInfo.GenericTypeArguments[0]; + return typeInfo.IsAssignableFrom(elementType.MakeArrayType().GetTypeInfo()) ? elementType : null; + } + + return null; + } + + // Copied rather than shared: the view may only cover a part of its buffer, and the CLR + // side keeps what it was given while the script is free to change the buffer afterwards. + private static Byte[] GetBytes(ObjectInstance source) + { + if (source is JsArrayBuffer buffer) + { + return (Byte[])((Byte[])buffer.ToObject()).Clone(); + } + + var whole = (Byte[])source.Get("buffer").ToObject(); + var offset = (Int32)TypeConverter.ToNumber(source.Get("byteOffset")); + var length = (Int32)TypeConverter.ToNumber(source.Get("byteLength")); + var bytes = new Byte[length]; + Buffer.BlockCopy(whole, offset, bytes, 0, length); + return bytes; + } + + private static Boolean TryCreateSequence(ObjectInstance source, Type targetType, EngineInstance engine, out Object result) + { + var elementType = targetType.GetSequenceElementType(); + result = null; + + if (elementType == null) + { + return false; + } + + if (source is not JsArray items) + { + // WebIDL takes any iterable (e.g. a Set), but not an array-like or plain object. + if (!source.Get(GlobalSymbolRegistry.Iterator).IsCallable()) + { + return false; + } + + var from = engine.Jint.Intrinsics.Array.Get("from"); + items = (JsArray)engine.Jint.Call(from, engine.Jint.Intrinsics.Array, new JsValue[] { source }); + } + + var length = (Int32)items.Length; + var array = Array.CreateInstance(elementType, length); + + for (var i = 0; i < length; i++) + { + array.SetValue(items[i].As(elementType, engine), i); + } + + result = array; + return true; + } + + // WebIDL's conversion of a number to an integer type: the fraction is dropped, what is + // not a finite number becomes zero, and what is out of range wraps around. + private static Object ToNumeric(Double number, TypeCode code) + { + var integer = Double.IsNaN(number) || Double.IsInfinity(number) ? 0.0 : Math.Truncate(number); + + switch (code) + { + case TypeCode.Single: + return (Single)number; + case TypeCode.Decimal: + return number >= (Double)Decimal.MinValue && number < (Double)Decimal.MaxValue ? (Decimal)number : 0m; + case TypeCode.Byte: + return unchecked((Byte)(Int64)Wrap(integer)); + case TypeCode.SByte: + return unchecked((SByte)(Int64)Wrap(integer)); + case TypeCode.Int16: + return unchecked((Int16)(Int64)Wrap(integer)); + case TypeCode.Int64: + return unchecked((Int64)Wrap(integer)); + default: + return integer < 0 ? unchecked((UInt64)(Int64)Wrap(integer)) : number >= UInt64.MaxValue ? 0UL : (UInt64)integer; + } + } + + private static Double Wrap(Double integer) => + Math.Abs(integer) < 9.2233720368547748E+18 ? integer : Math.IEEERemainder(integer, 18446744073709551616.0); + private static Object AsComplex(this JsValue value, Type targetType, EngineInstance engine) { var obj = value.FromJsValue(); var sourceType = obj.GetType(); + // Ahead of the check below, as otherwise an Object parameter would receive the Jint + // object, about which a library can only learn "[object Uint8Array]" (e.g. a body). + if (obj is ObjectInstance binary && value.IsBufferSource()) + { + if (targetType == typeof(Byte[]) || targetType == typeof(Object)) + { + return GetBytes(binary); + } + + if (targetType == typeof(ArraySegment)) + { + return new ArraySegment(GetBytes(binary)); + } + } + if (sourceType == targetType || sourceType.GetTypeInfo().IsSubclassOf(targetType) || targetType.GetTypeInfo().IsAssignableFrom(sourceType.GetTypeInfo())) { return obj; @@ -41,6 +168,11 @@ private static Object AsComplex(this JsValue value, Type targetType, EngineInsta } } + if (obj is ObjectInstance sequence && TryCreateSequence(sequence, targetType, engine, out var items)) + { + return items; + } + if (obj is ObjectInstance record && TryCreateRecord(record, targetType, engine, out var entries)) { return entries; @@ -51,10 +183,20 @@ private static Object AsComplex(this JsValue value, Type targetType, EngineInsta return created; } + // A class converting implicitly from several types is how a WebIDL union such as + // (DOMString or sequence) is written, as a member cannot be overloaded. + var conversions = targetType.GetImplicitConversions(); + + if (conversions.Length > 0) + { + var conversion = conversions.SelectOverload(new[] { value }); + return conversion.Invoke(null, new[] { value.As(conversion.GetParameters()[0].ParameterType, engine) }); + } + var method = sourceType.PrepareConvert(targetType) ?? throw new JavaScriptException("[Internal] Could not find corresponding cast target."); - return method.Invoke(obj, null); + return method.IsStatic ? method.Invoke(null, new[] { obj }) : method.Invoke(obj, null); } /// @@ -229,6 +371,15 @@ public static Object As(this JsValue value, Type targetType, EngineInstance engi var raw = Convert.ChangeType(TypeConverter.ToNumber(value), underlyingType); return Enum.ToObject(targetType, raw); } + else if (Nullable.GetUnderlyingType(targetType) is Type nullableType) + { + return value.IsUndefined() ? null : value.As(nullableType, engine); + } + else if (targetType == typeof(UInt64) || targetType == typeof(Int64) || targetType == typeof(Single) || + targetType == typeof(Int16) || targetType == typeof(Byte) || targetType == typeof(SByte) || targetType == typeof(Decimal)) + { + return ToNumeric(TypeConverter.ToNumber(value), Type.GetTypeCode(targetType)); + } else { return value.AsComplex(targetType, engine); diff --git a/src/AngleSharp.Js/Extensions/OverloadResolution.cs b/src/AngleSharp.Js/Extensions/OverloadResolution.cs index 68c1de2a..d96c9b3f 100644 --- a/src/AngleSharp.Js/Extensions/OverloadResolution.cs +++ b/src/AngleSharp.Js/Extensions/OverloadResolution.cs @@ -3,6 +3,9 @@ namespace AngleSharp.Js using Jint; using Jint.Native; using Jint.Native.Function; + using Jint.Native.Object; + using Jint.Native.Symbol; + using AngleSharp.Js.Cache; using System; using System.Reflection; @@ -13,7 +16,8 @@ namespace AngleSharp.Js /// static class OverloadResolution { - public static MethodInfo SelectOverload(this MethodInfo[] overloads, JsValue[] arguments) + public static T SelectOverload(this T[] overloads, JsValue[] arguments) + where T : MethodBase { var count = arguments.Length; @@ -40,11 +44,10 @@ public static MethodInfo SelectOverload(this MethodInfo[] overloads, JsValue[] a return best; } - private static Int32 Score(MethodInfo method, JsValue[] arguments, Int32 count) + private static Int32 Score(MethodBase 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 offset = MethodDescription.Of(method).HasReceiver ? 1 : 0; var available = parameters.Length - offset; var required = 0; @@ -106,12 +109,30 @@ private static Int32 Match(JsValue value, Type type) 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; + if (value.IsBufferSource()) + { + return target == typeof(Byte[]) || target == typeof(ArraySegment) ? 3 : target == typeof(Object) ? 1 : 0; + } + + if (value.IsArray()) + { + // Bytes also take an array of numbers, but that is rarely what such an overload is for. + return target == typeof(Byte[]) ? 2 : target.GetSequenceElementType() != null ? 3 : info.IsClass && target != typeof(String) ? 1 : 0; + } + + if (target.GetSequenceElementType() != null) + { + // Other iterables also make a sequence, e.g. a Set. + return value is ObjectInstance iterable && iterable.Get(GlobalSymbolRegistry.Iterator).IsCallable() ? 3 : 0; + } + + // A plain object fits a dictionary (e.g. ScrollToOptions), not a primitive or sequence. + return info.IsClass && target != typeof(String) && !target.IsArray ? 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); + type == typeof(UInt32) || type == typeof(UInt16) || type == typeof(Int16) || type == typeof(Byte) || + type == typeof(UInt64) || type == typeof(SByte) || type == typeof(Decimal); } } diff --git a/src/AngleSharp.Js/JsScriptingService.cs b/src/AngleSharp.Js/JsScriptingService.cs index 74a95ba5..41650c94 100644 --- a/src/AngleSharp.Js/JsScriptingService.cs +++ b/src/AngleSharp.Js/JsScriptingService.cs @@ -4,6 +4,7 @@ namespace AngleSharp.Scripting using AngleSharp.Dom; using AngleSharp.Io; using AngleSharp.Js; + using AngleSharp.Js.Dom; using AngleSharp.Text; using Jint; using System; @@ -111,6 +112,13 @@ await options.EventLoop.EnqueueAsync(_ => public Object EvaluateScript(IDocument document, String source, String type, String sourceUrl) { document = document ?? throw new ArgumentNullException(nameof(document)); + + // a sandboxed frame shows its document, and runs nothing of it + if (OriginPolicy.IsScriptingBlocked(document)) + { + return null; + } + return GetOrCreateInstance(document).RunScript(source, type, sourceUrl).FromJsValue(); } diff --git a/src/AngleSharp.Js/Proxies/DomConstructorInstance.cs b/src/AngleSharp.Js/Proxies/DomConstructorInstance.cs index e0f01ab6..27cd1a3b 100644 --- a/src/AngleSharp.Js/Proxies/DomConstructorInstance.cs +++ b/src/AngleSharp.Js/Proxies/DomConstructorInstance.cs @@ -12,7 +12,7 @@ namespace AngleSharp.Js sealed class DomConstructorInstance : Constructor { - private readonly ConstructorInfo _constructor; + private readonly ConstructorInfo[] _constructors; private readonly EngineInstance _instance; private readonly ObjectInstance _objectPrototype; private readonly Type _type; @@ -23,7 +23,7 @@ public DomConstructorInstance(EngineInstance engine, ConstructorDefinition defin var toString = new ClrFunction(Engine, "toString", ToString); _objectPrototype = engine.GetDomPrototype(definition.Type); _instance = engine; - _constructor = definition.Info; + _constructors = definition.Overloads; _type = definition.Type; // Jint's Constructor leaves the prototype at Object.prototype, which would make a @@ -36,6 +36,31 @@ public DomConstructorInstance(EngineInstance engine, ConstructorDefinition defin // "constructor" as a slot resolved on first read, and that read arrives here, so the // two directions still meet at one object without this end having to reach over. SetOwnProperty("prototype", new PropertyDescriptor(_objectPrototype, false, false, false)); + + AddStaticMembers(definition.Type.GetStaticMembers()); + } + + private void AddStaticMembers(StaticMember[] members) + { + foreach (var member in members) + { + var name = member.Name; + + if (member.IsAccessor) + { + var getter = member.Getter; + var setter = member.Setter; + var get = getter == null ? null : new ClrFunction(Engine, "get " + name, (_, arguments) => _instance.CallStatic(getter, arguments)); + var set = setter == null ? null : new ClrFunction(Engine, "set " + name, (_, arguments) => _instance.CallStatic(setter, arguments)); + SetOwnProperty(name, new GetSetPropertyDescriptor(get, set, true, true)); + } + else + { + var overloads = member.Overloads; + var function = new ClrFunction(Engine, name, (_, arguments) => _instance.CallStatic(overloads.SelectOverload(arguments), arguments)); + SetOwnProperty(name, new PropertyDescriptor(function, true, true, true)); + } + } } /// @@ -117,17 +142,23 @@ private Boolean InheritsFromPrototype(ObjectInstance obj) public override ObjectInstance Construct(JsValue[] arguments, JsValue newTarget) { - if (_constructor == null) + if (_constructors.Length == 0) { throw new JavaScriptException("Illegal constructor."); } try { - var parameters = _instance.BuildArgs(_constructor, arguments); - var obj = _constructor.Invoke(parameters); + var constructor = _constructors.Length == 1 ? _constructors[0] : _constructors.SelectOverload(arguments); + var parameters = _instance.BuildArgs(constructor, arguments); + var obj = constructor.Invoke(parameters); return _instance.GetDomNode(obj); } + catch (TargetInvocationException ex) + { + // The message is all a script gets to see of what went wrong. + throw new JavaScriptException(_instance.Jint.Intrinsics.Error, ex.InnerException?.Message); + } catch { throw new JavaScriptException(_instance.Jint.Intrinsics.Error); @@ -136,7 +167,7 @@ public override ObjectInstance Construct(JsValue[] arguments, JsValue newTarget) protected override JsValue Call(JsValue thisObject, JsValue[] arguments) { - if (_constructor != null) + if (_constructors.Length > 0) { throw new JavaScriptException("Only call the constructor with the new keyword."); } diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 8e973b69..2a5d1384 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -10,7 +10,7 @@ entry of CHANGELOG.md, which build/Build.cs passes in as -p:Version, so this value only affects a plain `dotnet build` and no longer needs bumping for a release. --> - 1.1.4 + 1.2.0 From aded36e33fd74d9b29b22118ff5f7eb03ef8276d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 3 Nov 2026 13:03:59 +0000 Subject: [PATCH 02/13] Initial plan From b2dc4a9ae71a40719f03a5d79871ab2c856c5609 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 3 Nov 2026 13:08:20 +0000 Subject: [PATCH 03/13] Wait for reply origin before asserting Co-authored-by: FlorianRappl <1766191+FlorianRappl@users.noreply.github.com> --- src/AngleSharp.Js.Tests/CrossOriginTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/AngleSharp.Js.Tests/CrossOriginTests.cs b/src/AngleSharp.Js.Tests/CrossOriginTests.cs index 14a09809..675268c2 100644 --- a/src/AngleSharp.Js.Tests/CrossOriginTests.cs +++ b/src/AngleSharp.Js.Tests/CrossOriginTests.cs @@ -171,7 +171,7 @@ public async Task MessageReachesAFrameOfAnotherOriginWithWhoSentIt() Eval(page, "document.getElementById('f').contentWindow.postMessage({ a: 1, b: [2, 'x'], c: { d: true } }, 'https://b.test')"); - await Until(() => Eval(frame, "window.seen") != "undefined" && Eval(page, "window.reply") == "back"); + await Until(() => Eval(frame, "window.seen") != "undefined" && Eval(page, "window.reply") == "back" && Eval(page, "window.replyOrigin") != "undefined"); Assert.AreEqual("{\"a\":1,\"b\":[2,\"x\"],\"c\":{\"d\":true}}", Eval(frame, "window.seen")); Assert.AreEqual("https://a.test", Eval(frame, "window.seenOrigin")); Assert.AreEqual("https://b.test", Eval(page, "window.replyOrigin")); From 676a3e27ba2ceb61a8495bc1d802ad690affc249 Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Wed, 4 Nov 2026 09:59:15 +0100 Subject: [PATCH 04/13] Improved JS compliance --- CHANGELOG.md | 11 + src/AngleSharp.Js.Tests/CallbackTests.cs | 150 ++++++++ src/AngleSharp.Js.Tests/EcmaTests.cs | 16 + .../JavascriptErrorTests.cs | 357 ++++++++++++++++++ src/AngleSharp.Js.Tests/PerformanceTests.cs | 177 +++++++++ src/AngleSharp.Js/Cache/ScriptCache.cs | 20 +- src/AngleSharp.Js/Dom/Performance.cs | 233 +++++++++++- src/AngleSharp.Js/EngineInstance.cs | 53 ++- src/AngleSharp.Js/Extensions/DomDelegates.cs | 55 ++- .../Extensions/EngineExtensions.cs | 10 + .../Extensions/JsValueExtensions.cs | 2 +- .../Extensions/OverloadResolution.cs | 2 +- src/AngleSharp.Js/JsScriptingService.cs | 81 +++- .../Proxies/DomEventDefinition.cs | 54 ++- 14 files changed, 1195 insertions(+), 26 deletions(-) create mode 100644 src/AngleSharp.Js.Tests/CallbackTests.cs create mode 100644 src/AngleSharp.Js.Tests/PerformanceTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 09bd53bf..c1836abd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ Released on Friday, November 6 2026. +- Fixed bound functions and callable proxies not being accepted as callbacks (e.g. `setTimeout(f.bind(x))`, `addEventListener('click', f.bind(x))`, `requestAnimationFrame(f.bind(x))`, `onclick`), failing with `Could not find corresponding cast target` +- Fixed `removeEventListener` not removing listeners, as a function was turned into another listener each time +- Fixed errors of event listeners not reaching the `error` listeners of the window; an error thrown by such a listener goes to the browsing context +- Fixed the address of a script getting lost, so errors and stack traces named ``: they tell the resolved address of the script, or of the document for one written into it +- Fixed scripts written into a document telling lines and columns of their own in errors and stack traces instead of the ones in the document +- Fixed errors of scripts and of callbacks (e.g. timers, animation frames) not reaching the `error` listeners of the window; what they don't handle by `preventDefault` goes to the browsing context +- Fixed `onerror` of the window getting the event instead of the message, source, line, column and error, and returning `true` not handling the error +- Fixed `error` of an `ErrorEvent` being a .NET exception instead of what script threw +- Fixed an `onclick` and the like throwing taking down the process and skipping the other listeners, as it was not reported like a listener +- Fixed a `TypeError` or the like thrown for script by a member of the DOM becoming a plain `Error` - Fixed only the first of several methods or constructors of the same name being callable (e.g. `new URLSearchParams('a=1')`, `register(url, options)`); the one fitting the arguments is called - Fixed static members not being reachable (e.g. `DOMRect.fromRect`); they are on the constructor and may take the window as first parameter - Fixed members of classes marked with `DomExposed` ending up on what they are exposed to (e.g. the members of a constructor on `window`); only extension methods do @@ -17,6 +27,7 @@ Released on Friday, November 6 2026. - Added `ArraySegment` being given as a `Uint8Array` - Added classes with implicit conversions standing for a WebIDL union, e.g. of a string and a sequence of strings, which fixes such conversions failing; an implicit conversion from such a class is what a script receives for it - Added the same-origin policy: script gets `null` for a node of another origin, and for its window a `CrossOriginWindow` with only `window`, `self`, `frames`, `parent`, `top`, `closed`, `length`, `location` (only to be set, and only for frames inside the own document), `postMessage`, `focus`, `blur` and `close` +- Added `performance.mark`, `measure`, `getEntries`, `getEntriesByName`, `getEntriesByType`, `clearMarks`, `clearMeasures`, along with `PerformanceEntry`, `PerformanceMark` and `PerformanceMeasure` # 1.1.4 diff --git a/src/AngleSharp.Js.Tests/CallbackTests.cs b/src/AngleSharp.Js.Tests/CallbackTests.cs new file mode 100644 index 00000000..32109683 --- /dev/null +++ b/src/AngleSharp.Js.Tests/CallbackTests.cs @@ -0,0 +1,150 @@ +namespace AngleSharp.Js.Tests +{ + using AngleSharp.Dom; + using AngleSharp.Html.Dom; + using NUnit.Framework; + using System; + using System.Threading.Tasks; + + /// + /// What a script hands to the DOM as a callback: anything callable, not just plain functions. + /// + [TestFixture] + public class CallbackTests + { + private static async Task OpenAsync(String script) + { + var config = Configuration.Default.WithJs().WithEventLoop(); + return await BrowsingContext.New(config).OpenAsync(m => m.Content($"")); + } + + private static IHtmlElement Button(IDocument document) => (IHtmlElement)document.GetElementById("b"); + + private static async Task WaitForTitle(IDocument document, String title) + { + for (var i = 0; i < 200 && document.Title != title; i++) + { + await Task.Delay(10).ConfigureAwait(false); + } + + Assert.AreEqual(title, document.Title); + } + + [Test] + public async Task TimerRunsBoundFunctionWithItsThisAndArguments() + { + var document = await OpenAsync(@" + function done(a, b) { document.title = this.name + a + b; } + setTimeout(done.bind({ name: 'bound' }, 1, 2), 0);"); + + await WaitForTitle(document, "bound12"); + } + + [Test] + public async Task EventListenerRunsBoundFunction() + { + var document = await OpenAsync(@" + function clicked(ev) { document.title = this.name + ev.type; } + document.getElementById('b').addEventListener('click', clicked.bind({ name: 'bound-' }));"); + + Button(document).DoClick(); + + await WaitForTitle(document, "bound-click"); + } + + [Test] + public async Task EventListenerRunsBoundFunctionOfBoundFunction() + { + var document = await OpenAsync(@" + function clicked() { document.title = this.name; } + document.getElementById('b').addEventListener('click', clicked.bind({ name: 'first' }).bind({ name: 'second' }));"); + + Button(document).DoClick(); + + await WaitForTitle(document, "first"); + } + + [Test] + public async Task EventListenerRunsCallableProxy() + { + var document = await OpenAsync(@" + var proxy = new Proxy(function () { document.title = 'proxied'; }, {}); + document.getElementById('b').addEventListener('click', proxy);"); + + Button(document).DoClick(); + + await WaitForTitle(document, "proxied"); + } + + [Test] + public async Task EventHandlerPropertyRunsAndHandsBackBoundFunction() + { + var document = await OpenAsync(@" + var button = document.getElementById('b'); + var handler = function () { document.title = this.name; }.bind({ name: 'handled' }); + button.onclick = handler; + document.body.setAttribute('data-same', String(button.onclick === handler));"); + + Button(document).DoClick(); + + await WaitForTitle(document, "handled"); + Assert.AreEqual("true", document.Body.GetAttribute("data-same")); + } + + [Test] + public async Task EventHandlerPropertyIgnoresWhatIsNotCallable() + { + var document = await OpenAsync(@" + var button = document.getElementById('b'); + button.onclick = { name: 'no function' }; + document.body.setAttribute('data-value', String(button.onclick));"); + + Assert.AreEqual("null", document.Body.GetAttribute("data-value")); + } + + [Test] + public async Task RemovedListenerIsNotCalledAnymore() + { + var document = await OpenAsync(@" + var listener = function () { document.title = 'called'; }; + var button = document.getElementById('b'); + button.addEventListener('click', listener); + button.removeEventListener('click', listener);"); + var clicks = 0; + Button(document).AddEventListener("click", (s, e) => clicks++); + + Button(document).DoClick(); + + for (var i = 0; i < 300 && clicks == 0; i++) + { + await Task.Delay(10).ConfigureAwait(false); + } + + Assert.AreEqual(1, clicks); + Assert.AreEqual(String.Empty, document.Title); + } + + [Test] + public async Task RemovedBoundListenerIsNotCalledAnymore() + { + var document = await OpenAsync(@" + var calls = 0; + var listener = function () { document.title = 'called' + (++calls); }.bind(null); + var button = document.getElementById('b'); + button.addEventListener('click', listener); + button.removeEventListener('click', listener);"); + var clicks = 0; + Button(document).AddEventListener("click", (_, _) => clicks++); + + Button(document).DoClick(); + + for (var i = 0; i < 300 && clicks == 0; i++) + { + await Task.Delay(10).ConfigureAwait(false); + } + + Assert.AreEqual(1, clicks); + Assert.AreEqual(String.Empty, document.Title); + } + } +} diff --git a/src/AngleSharp.Js.Tests/EcmaTests.cs b/src/AngleSharp.Js.Tests/EcmaTests.cs index d1c5ce90..b63af1a7 100644 --- a/src/AngleSharp.Js.Tests/EcmaTests.cs +++ b/src/AngleSharp.Js.Tests/EcmaTests.cs @@ -40,6 +40,22 @@ public async Task ModuleScriptShouldRun() Assert.IsNull(document.GetElementById("test")); } + [Test] + public async Task SeveralInlineModuleScriptsOfADocumentShouldAllRun() + { + var config = Configuration.Default + .WithJs() + .WithNavigator() + .WithDefaultLoader(new LoaderOptions() { IsResourceLoadingEnabled = true }); + var context = BrowsingContext.New(config); + var html = "
" + + "" + + ""; + var document = await context.OpenAsync(r => r.Address("https://example.com/page").Content(html)); + Assert.IsNull(document.GetElementById("first")); + Assert.IsNull(document.GetElementById("second")); + } + [Test] public async Task InlineModuleScriptShouldRun() { diff --git a/src/AngleSharp.Js.Tests/JavascriptErrorTests.cs b/src/AngleSharp.Js.Tests/JavascriptErrorTests.cs index 87068d93..48d4b168 100644 --- a/src/AngleSharp.Js.Tests/JavascriptErrorTests.cs +++ b/src/AngleSharp.Js.Tests/JavascriptErrorTests.cs @@ -1,8 +1,14 @@ namespace AngleSharp.Js.Tests { using AngleSharp.Dom; + using AngleSharp.Dom.Events; + using AngleSharp.Html.Dom; + using AngleSharp.Io; + using AngleSharp.Js.Tests.Mocks; + using Jint.Runtime; using NUnit.Framework; using System; + using System.Collections.Generic; using System.Threading.Tasks; [TestFixture] @@ -50,6 +56,357 @@ public async Task JavascriptErrorInTimerIsTrackedByBrowsingContext() Assert.IsInstanceOf(errors[0]); } + private static async Task Eventually(Func condition) + { + for (var i = 0; i < 300 && !condition(); i++) + { + await Task.Delay(10).ConfigureAwait(false); + } + } + + private static async Task<(IDocument Document, List Errors)> OpenWithOnError(String script, String address = "https://example.com/page") + { + var context = BrowsingContext.New(Configuration.Default.WithJs().WithEventLoop()); + var errors = context.CollectErrors(); + var document = await context.OpenAsync(r => r.Address(address).Content($@"")); + return (document, errors); + } + + [Test] + public async Task OnErrorGetsTheDetailsOfAnErrorInAListener() + { + var (document, _) = await OpenWithOnError("document.getElementById('b').addEventListener('click', function () { throw new TypeError('broken'); });"); + + ((IHtmlElement)document.GetElementById("b")).DoClick(); + await Eventually(() => document.Title.Length > 0); + + var parts = document.Title.Split('|'); + Assert.AreEqual("string", parts[0]); + StringAssert.Contains("broken", parts[1]); + Assert.AreEqual("https://example.com/page", parts[2]); + Assert.AreEqual("true", parts[5]); + Assert.AreEqual("broken", parts[6]); + } + + [Test] + public async Task OnErrorGetsAnErrorOfATimer() + { + var (document, errors) = await OpenWithOnError("setTimeout(function () { throw new TypeError('late'); }, 0);"); + + await Eventually(() => document.Title.Length > 0); + + StringAssert.Contains("late", document.Title); + await Eventually(() => errors.Count > 0); + Assert.AreEqual(1, errors.Count); + } + + [Test] + public async Task OnErrorGetsAnErrorOfAScriptAndItsLoadEventStillFires() + { + var (document, errors) = await OpenWithOnError("throw new TypeError('at once');"); + + StringAssert.Contains("at once", document.Title); + Assert.AreEqual(1, errors.Count); + } + + [Test] + public async Task OnErrorReturningTrueHandlesTheError() + { + var (document, errors) = await OpenWithOnError("HANDLEDthrow new TypeError('handled');"); + + StringAssert.Contains("handled", document.Title); + Assert.AreEqual(0, errors.Count); + } + + [Test] + public async Task ErrorListenerPreventingTheDefaultHandlesTheError() + { + var context = BrowsingContext.New(Configuration.Default.WithJs().WithEventLoop()); + var errors = context.CollectErrors(); + var document = await context.OpenAsync(r => r.Content(@"")); + + Assert.AreEqual("truequiet", document.Title); + Assert.AreEqual(0, errors.Count); + } + + [Test] + public async Task EventHandlerPropertyThrowingDoesNotStopOtherListeners() + { + var context = BrowsingContext.New(Configuration.Default.WithJs().WithEventLoop()); + var errors = context.CollectErrors(); + var document = await context.OpenAsync(r => r.Content(@"")); + + ((IHtmlElement)document.GetElementById("b")).DoClick(); + await Eventually(() => document.Title == "next"); + + Assert.AreEqual("next", document.Title); + Assert.AreEqual(1, errors.Count); + } + + [Test] + public async Task OnErrorThrowingDoesNotCallItselfAgain() + { + var context = BrowsingContext.New(Configuration.Default.WithJs().WithEventLoop()); + var errors = context.CollectErrors(); + var document = await context.OpenAsync(r => r.Content(@"")); + + Assert.AreEqual("1", document.Title); + Assert.AreEqual(2, errors.Count); + } + + [Test] + public async Task OnErrorOfAnElementGetsTheEvent() + { + var context = BrowsingContext.New(Configuration.Default.WithJs().WithEventLoop()); + var document = await context.OpenAsync(r => r.Content(@"")); + + Assert.AreEqual("object1", document.Title); + } + + [Test] + public async Task InlineScriptReportsItsPositionInTheDocument() + { + var context = BrowsingContext.New(Configuration.Default.WithJs().WithEventLoop()); + var errors = context.CollectErrors(); + await context.OpenAsync(r => r.Address("https://example.com/page").Content("\n

one

\n

two

\n ")); + + var location = ((JavaScriptException)errors[0]).Location; + Assert.AreEqual(4, location.Start.Line); + // of the new, as that made the error + Assert.AreEqual(27, location.Start.Column); + StringAssert.Contains("https://example.com/page:4:", ((JavaScriptException)errors[0]).JavaScriptStackTrace); + } + + [Test] + public async Task InlineScriptOverSeveralLinesReportsItsPositionInTheDocument() + { + var context = BrowsingContext.New(Configuration.Default.WithJs().WithEventLoop()); + var errors = context.CollectErrors(); + await context.OpenAsync(r => r.Content("\n")); + + var location = ((JavaScriptException)errors[0]).Location; + Assert.AreEqual(4, location.Start.Line); + Assert.AreEqual(8, location.Start.Column); + } + + [Test] + public async Task InlineScriptsWithTheSameSourceReportTheirOwnPositions() + { + const String script = ""; + var context = BrowsingContext.New(Configuration.Default.WithJs().WithEventLoop()); + var errors = context.CollectErrors(); + await context.OpenAsync(r => r.Content($"\n{script}\n\n{script}")); + + Assert.AreEqual(2, errors.Count); + Assert.AreEqual(2, ((JavaScriptException)errors[0]).Location.Start.Line); + Assert.AreEqual(4, ((JavaScriptException)errors[1]).Location.Start.Line); + } + + [Test] + public async Task InlineScriptOfADocumentWithWindowsLineEndsReportsItsPosition() + { + var context = BrowsingContext.New(Configuration.Default.WithJs().WithEventLoop()); + var errors = context.CollectErrors(); + await context.OpenAsync(r => r.Content("\r\n

one

\r\n")); + + Assert.AreEqual(5, ((JavaScriptException)errors[0]).Location.Start.Line); + } + + [Test] + public async Task ScriptAddedByAScriptReportsPositionsOfItsOwn() + { + var context = BrowsingContext.New(Configuration.Default.WithJs().WithEventLoop()); + var errors = context.CollectErrors(); + await context.OpenAsync(r => r.Content("\n\n\n")); + + Assert.AreEqual(1, errors.Count); + Assert.AreEqual(1, ((JavaScriptException)errors[0]).Location.Start.Line); + } + + [Test] + public async Task ExternalScriptKeepsItsOwnPositions() + { + var requester = new MockHttpClientRequester(new Dictionary + { + { "/app.js", "var a = 1;\nthrow new Error('second line');" }, + }); + var config = Configuration.Default.WithJs().WithEventLoop().With(requester) + .WithDefaultLoader(new LoaderOptions { IsResourceLoadingEnabled = true }); + var context = BrowsingContext.New(config); + var errors = context.CollectErrors(); + await context.OpenAsync(r => r.Address("https://example.com/").Content("\n\n\n\n")); + + Assert.AreEqual(2, ((JavaScriptException)errors[0]).Location.Start.Line); + } + + [Test] + public async Task JavascriptErrorInListenerReachesErrorListenersOfTheWindow() + { + var context = BrowsingContext.New(Configuration.Default.WithJs().WithEventLoop()); + var document = await context.OpenAsync(r => r.Content(@"")); + + ((IHtmlElement)document.GetElementById("b")).DoClick(); + await Eventually(() => document.Title.Length > 0); + + StringAssert.StartsWith("error|", document.Title); + StringAssert.Contains("broken", document.Title); + } + + [Test] + public async Task JavascriptErrorInListenerTellsItsScript() + { + var requester = new MockHttpClientRequester(new Dictionary + { + { "/app.js", "document.getElementById('b').addEventListener('click', function () { throw new Error('x'); });" }, + }); + var config = Configuration.Default.WithJs().WithEventLoop().With(requester) + .WithDefaultLoader(new LoaderOptions { IsResourceLoadingEnabled = true }); + var context = BrowsingContext.New(config); + var document = await context.OpenAsync(r => r.Address("https://example.com/").Content(@"")); + await document.WhenStable().ConfigureAwait(false); + ErrorEvent received = null; + document.DefaultView.AddEventListener("error", (_, ev) => received = ev as ErrorEvent); + + ((IHtmlElement)document.GetElementById("b")).DoClick(); + await Eventually(() => received != null); + + Assert.IsNotNull(received); + Assert.AreEqual("https://example.com/app.js", received.FileName); + Assert.AreEqual(1, received.Line); + } + + [Test] + public async Task ErrorListenerThrowingIsTrackedInsteadOfReportedToItself() + { + var context = BrowsingContext.New(Configuration.Default.WithJs().WithEventLoop()); + var errors = context.CollectErrors(); + var document = await context.OpenAsync(r => r.Content(@"")); + var button = (IHtmlElement)document.GetElementById("b"); + + button.DoClick(); + await Eventually(() => errors.Count > 0); + + Assert.AreEqual("1", document.Title); + await Eventually(() => errors.Count == 2); + Assert.AreEqual(2, errors.Count); + StringAssert.Contains("from the error listener", errors[0].Message); + StringAssert.Contains("from the listener", errors[1].Message); + + // the next error is reported again + button.DoClick(); + await Eventually(() => document.Title == "2"); + + Assert.AreEqual("2", document.Title); + } + + [Test] + public void ThrownErrorTellsTheAddressGivenWithTheScript() + { + var document = BrowsingContext.New(Configuration.Default.WithJs().WithEventLoop()).OpenNewAsync().Result; + + try + { + document.ExecuteScript("function thrower() { throw new Error('x'); }\nthrower();", sourceUrl: "https://example.com/a.js"); + Assert.Fail("The script should have thrown."); + } + catch (JavaScriptException ex) + { + Assert.AreEqual("https://example.com/a.js", ex.Location.SourceFile); + StringAssert.Contains("https://example.com/a.js", ex.JavaScriptStackTrace); + StringAssert.Contains("thrower", ex.JavaScriptStackTrace); + } + } + + [Test] + public void SameScriptTellsTheAddressItWasRunFrom() + { + const String source = "throw new Error('same source');"; + var document = BrowsingContext.New(Configuration.Default.WithJs().WithEventLoop()).OpenNewAsync().Result; + + foreach (var url in new[] { "https://example.com/first.js", "https://example.com/second.js" }) + { + var ex = Assert.Throws(() => document.ExecuteScript(source, sourceUrl: url)); + Assert.AreEqual(url, ex.Location.SourceFile); + } + } + + [Test] + public void ScriptWithSyntaxErrorTellsTheAddress() + { + var document = BrowsingContext.New(Configuration.Default.WithJs().WithEventLoop()).OpenNewAsync().Result; + + var ex = Assert.Catch(() => document.ExecuteScript("function (", sourceUrl: "https://example.com/broken.js")); + + StringAssert.Contains("https://example.com/broken.js", ex.ToString()); + } + + [Test] + public async Task ExternalScriptReportsItsResolvedAddress() + { + var requester = new MockHttpClientRequester(new Dictionary + { + { "/js/app.js", "setTimeout(function () { throw new Error('late'); }, 0);" }, + }); + var config = Configuration.Default.WithJs().WithEventLoop().With(requester) + .WithDefaultLoader(new LoaderOptions { IsResourceLoadingEnabled = true }); + var context = BrowsingContext.New(config); + var errors = context.CollectErrors(); + var document = await context.OpenAsync(r => r.Address("https://example.com/page").Content("")); + + await Task.Delay(100).ConfigureAwait(false); + await document.WhenStable().ConfigureAwait(false); + + Assert.AreEqual(1, errors.Count); + Assert.AreEqual("https://example.com/js/app.js", ((JavaScriptException)errors[0]).Location.SourceFile); + } + + [Test] + public async Task InlineScriptReportsTheAddressOfItsDocument() + { + var context = BrowsingContext.New(Configuration.Default.WithJs().WithEventLoop()); + var errors = context.CollectErrors(); + var document = await context.OpenAsync(r => r.Address("https://example.com/page").Content("")); + + await Task.Delay(100).ConfigureAwait(false); + await document.WhenStable().ConfigureAwait(false); + + Assert.AreEqual(1, errors.Count); + Assert.AreEqual("https://example.com/page", ((JavaScriptException)errors[0]).Location.SourceFile); + } + [Test] public async Task CanComputeAndHoistLocalVariablesInForLoop() { diff --git a/src/AngleSharp.Js.Tests/PerformanceTests.cs b/src/AngleSharp.Js.Tests/PerformanceTests.cs new file mode 100644 index 00000000..f6c6a59d --- /dev/null +++ b/src/AngleSharp.Js.Tests/PerformanceTests.cs @@ -0,0 +1,177 @@ +namespace AngleSharp.Js.Tests +{ + using NUnit.Framework; + using System; + using System.Threading.Tasks; + + /// + /// The user timing of a window: performance.mark, measure and what they leave behind. + /// + [TestFixture] + public class PerformanceTests + { + private static Task Eval(String expression) => $"(function () {{ {expression} }})()".EvalScriptAsync(); + + [Test] + public async Task MarkReturnsAnEntry() + { + var result = await Eval(@" + var m = performance.mark('a'); + return [m.name, m.entryType, typeof m.startTime, m.duration, m.detail, m instanceof PerformanceMark, m instanceof PerformanceEntry].join();"); + + Assert.AreEqual("a,mark,number,0,,true,true", result); + } + + [Test] + public async Task MarkTakesItsStartTimeAndDetail() + { + var result = await Eval(@" + var m = performance.mark('a', { startTime: 12.5, detail: { foo: 'bar' } }); + return [m.startTime, m.detail.foo].join();"); + + Assert.AreEqual("12.5,bar", result); + } + + [Test] + public async Task MarkWithoutANameIsAnError() + { + var result = await Eval("try { performance.mark(); return 'no error'; } catch (e) { return e.name; }"); + + Assert.AreEqual("TypeError", result); + } + + [Test] + public async Task EntriesAreRealArraysSortedByTheirStart() + { + var result = await Eval(@" + performance.mark('late', { startTime: 20 }); + performance.mark('early', { startTime: 10 }); + performance.measure('both', 'early', 'late'); + var entries = performance.getEntries(); + return [Array.isArray(entries), entries.map(function (e) { return e.name; }).join('+')].join();"); + + Assert.AreEqual("true,early+both+late", result); + } + + [Test] + public async Task EntriesAreFoundByNameAndType() + { + var result = await Eval(@" + performance.mark('a', { startTime: 1 }); + performance.mark('b', { startTime: 2 }); + performance.mark('a', { startTime: 3 }); + performance.measure('a', { start: 1, end: 2 }); + return [ + performance.getEntriesByName('a').length, + performance.getEntriesByName('a', 'mark').length, + performance.getEntriesByName('a', 'measure').length, + performance.getEntriesByType('mark').length, + performance.getEntriesByType('measure').length, + performance.getEntriesByType('nothing').length + ].join();"); + + Assert.AreEqual("3,2,1,3,1,0", result); + } + + [Test] + public async Task MeasureBetweenMarksUsesTheLatestOfAName() + { + var result = await Eval(@" + performance.mark('start', { startTime: 1 }); + performance.mark('start', { startTime: 10 }); + performance.mark('end', { startTime: 25 }); + var m = performance.measure('m', 'start', 'end'); + return [m.name, m.entryType, m.startTime, m.duration, m instanceof PerformanceMeasure].join();"); + + Assert.AreEqual("m,measure,10,15,true", result); + } + + [Test] + public async Task MeasureWithoutAnythingGoesFromTheOriginToNow() + { + var result = await Eval(@" + var m = performance.measure('m'); + return [m.startTime, m.duration >= 0, m.duration <= performance.now()].join();"); + + Assert.AreEqual("0,true,true", result); + } + + [Test] + public async Task MeasureTakesTheStartAndGoesOnUntilNow() + { + var result = await Eval(@" + performance.mark('s', { startTime: 0 }); + var m = performance.measure('m', 's'); + return [m.startTime, m.duration >= 0].join();"); + + Assert.AreEqual("0,true", result); + } + + [TestCase("{ start: 5, end: 8 }", "5,3")] + [TestCase("{ start: 5, duration: 2 }", "5,2")] + [TestCase("{ end: 8, duration: 2 }", "6,2")] + [TestCase("{ start: 'a', end: 'b' }", "2,5")] + public async Task MeasureTakesItsOptions(String options, String expected) + { + var result = await Eval($@" + performance.mark('a', {{ startTime: 2 }}); + performance.mark('b', {{ startTime: 7 }}); + var m = performance.measure('m', {options}); + return [m.startTime, m.duration].join();"); + + Assert.AreEqual(expected, result); + } + + [Test] + public async Task MeasureTakesItsDetail() + { + var result = await Eval("return performance.measure('m', { start: 1, end: 2, detail: 'info' }).detail;"); + + Assert.AreEqual("info", result); + } + + [Test] + public async Task MeasureWithAnUnknownMarkIsASyntaxError() + { + var result = await Eval("try { performance.measure('m', 'nothing'); return 'no error'; } catch (e) { return e.name; }"); + + Assert.AreEqual("SyntaxError", result); + } + + [Test] + public async Task MeasureWithStartEndAndDurationIsATypeError() + { + var result = await Eval("try { performance.measure('m', { start: 1, end: 2, duration: 3 }); return 'no error'; } catch (e) { return e.name; }"); + + Assert.AreEqual("TypeError", result); + } + + [Test] + public async Task EntriesAreCleared() + { + var result = await Eval(@" + performance.mark('a'); + performance.mark('b'); + performance.measure('c'); + performance.measure('d'); + performance.clearMarks('a'); + performance.clearMeasures('c'); + var named = performance.getEntries().map(function (e) { return e.name; }).sort().join('+'); + performance.clearMarks(); + performance.clearMeasures(); + return [named, performance.getEntries().length].join();"); + + Assert.AreEqual("b+d,0", result); + } + + [Test] + public async Task EntryConvertsToJson() + { + var result = await Eval(@" + var json = performance.mark('a', { startTime: 3 }).toJSON(); + return [json.name, json.entryType, json.startTime, json.duration].join();"); + + Assert.AreEqual("a,mark,3,0", result); + } + } +} diff --git a/src/AngleSharp.Js/Cache/ScriptCache.cs b/src/AngleSharp.Js/Cache/ScriptCache.cs index 8a639122..286b24a8 100644 --- a/src/AngleSharp.Js/Cache/ScriptCache.cs +++ b/src/AngleSharp.Js/Cache/ScriptCache.cs @@ -1,5 +1,6 @@ namespace AngleSharp.Js.Cache { + using Acornima; using Acornima.Ast; using Jint; using Jint.Runtime; @@ -27,9 +28,9 @@ static class ScriptCache /// 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", + "importmap" => $"", + "dynamic" => $"", + "script" => $"", + _ => $"", + }; + var config = Configuration.Default + .WithJs() + .WithEventLoop() + .With(new MockHttpClientRequester(new Dictionary())) + .WithDefaultLoader(new LoaderOptions { IsResourceLoadingEnabled = true }); + var html = "
pending
" + page; + var document = await BrowsingContext.New(config) + .OpenAsync(r => r.Content(html).Address("https://example.com/")) + .WhenStable() + .ConfigureAwait(false); + Assert.AreEqual("pending", document.GetElementById("test").TextContent); + } + finally + { + File.Delete(json); + File.Delete(script); + } + } } } diff --git a/src/AngleSharp.Js/JsModuleLoader.cs b/src/AngleSharp.Js/JsModuleLoader.cs index ec547c4d..acc0154b 100644 --- a/src/AngleSharp.Js/JsModuleLoader.cs +++ b/src/AngleSharp.Js/JsModuleLoader.cs @@ -80,16 +80,27 @@ public override ResolvedSpecifier Resolve(string referencingModuleLocation, Modu return base.Resolve(referencingModuleLocation, moduleRequest); } + // Every module comes through AngleSharp's resource loader, whatever its scheme, so the + // requesters the host registered decide what a page can reach. The base implementations + // of both methods would read file: URLs straight from the local disk, for any page. protected override string LoadModuleContents(Engine engine, ResolvedSpecifier resolved) { - if (resolved.Uri?.Scheme.IsOneOf(ProtocolNames.Http, ProtocolNames.Https) == true) - { - return FetchModule(resolved.Uri); - } - else + return FetchModule(GetModuleUrl(resolved)); + } + + protected override Byte[] LoadModuleContentsAsBytes(Engine engine, ResolvedSpecifier resolved) + { + return FetchModuleBytes(GetModuleUrl(resolved)); + } + + private static Uri GetModuleUrl(ResolvedSpecifier resolved) + { + if (resolved.Uri == null) { - return base.LoadModuleContents(engine, resolved); + throw new NotSupportedException($"The module '{resolved.ModuleRequest.Specifier}' could not be resolved to a URL."); } + + return resolved.Uri; } private string FetchModule(Uri moduleUrl) @@ -99,11 +110,7 @@ private string FetchModule(Uri moduleUrl) return string.Empty; } - var importUrl = Url.Convert(moduleUrl); - - var request = new ResourceRequest(_scriptElement, importUrl); - - var response = _resourceLoader.FetchAsync(request).Task.Result; + var response = Fetch(moduleUrl); string content; @@ -114,5 +121,29 @@ private string FetchModule(Uri moduleUrl) return content; } + + private Byte[] FetchModuleBytes(Uri moduleUrl) + { + if (_resourceLoader == null) + { + return Array.Empty(); + } + + var response = Fetch(moduleUrl); + + using (var content = new MemoryStream()) + { + response.Content.CopyTo(content); + return content.ToArray(); + } + } + + private IResponse Fetch(Uri moduleUrl) + { + var importUrl = Url.Convert(moduleUrl); + var request = new ResourceRequest(_scriptElement, importUrl); + var response = _resourceLoader.FetchAsync(request).Task.Result; + return response ?? throw new InvalidOperationException($"No requester could load the module '{importUrl.Href}'."); + } } } From b344c15edcc5bf4311fb9269bf977388ebb9c457 Mon Sep 17 00:00:00 2001 From: Michael Thyregod <2386496+michaelthyregod@users.noreply.github.com> Date: Wed, 4 Nov 2026 11:07:02 +0100 Subject: [PATCH 06/13] Reference the pull request in the changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ae4fdfc9..7f76f178 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,7 +28,7 @@ Released on Friday, November 6 2026. - Added classes with implicit conversions standing for a WebIDL union, e.g. of a string and a sequence of strings, which fixes such conversions failing; an implicit conversion from such a class is what a script receives for it - Added the same-origin policy: script gets `null` for a node of another origin, and for its window a `CrossOriginWindow` with only `window`, `self`, `frames`, `parent`, `top`, `closed`, `length`, `location` (only to be set, and only for frames inside the own document), `postMessage`, `focus`, `blur` and `close` - Added `performance.mark`, `measure`, `getEntries`, `getEntriesByName`, `getEntriesByType`, `clearMarks`, `clearMeasures`, along with `PerformanceEntry`, `PerformanceMark` and `PerformanceMeasure` -- Fixed modules being read from the local disk; every module is loaded through the configured requesters (#150) @michaelthyregod +- Fixed modules being read from the local disk; every module is loaded through the configured requesters (#153) @michaelthyregod # 1.1.4 From 93092334e2c551410ed07a9b2c558472696fd8fc Mon Sep 17 00:00:00 2001 From: Michael Thyregod <2386496+michaelthyregod@users.noreply.github.com> Date: Thu, 5 Nov 2026 06:04:57 +0100 Subject: [PATCH 07/13] Resolve modules with Url against what imports them --- CHANGELOG.md | 1 + src/AngleSharp.Js.Tests/EcmaTests.cs | 26 ++++++ src/AngleSharp.Js/EngineInstance.cs | 2 +- src/AngleSharp.Js/JsModuleLoader.cs | 104 ++++++++++++++---------- src/AngleSharp.Js/JsScriptingService.cs | 8 +- 5 files changed, 92 insertions(+), 49 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f76f178..47bb3484 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ Released on Friday, November 6 2026. - Added the same-origin policy: script gets `null` for a node of another origin, and for its window a `CrossOriginWindow` with only `window`, `self`, `frames`, `parent`, `top`, `closed`, `length`, `location` (only to be set, and only for frames inside the own document), `postMessage`, `focus`, `blur` and `close` - Added `performance.mark`, `measure`, `getEntries`, `getEntriesByName`, `getEntriesByType`, `clearMarks`, `clearMeasures`, along with `PerformanceEntry`, `PerformanceMark` and `PerformanceMeasure` - Fixed modules being read from the local disk; every module is loaded through the configured requesters (#153) @michaelthyregod +- Fixed modules imported by a relative address being requested from the wrong address (e.g. `./a.js` of `/dir/page.html` from `/dir/page.html/a.js`, and the imports of a module of another origin from the own origin); they are resolved against what imports them (#153) @michaelthyregod # 1.1.4 diff --git a/src/AngleSharp.Js.Tests/EcmaTests.cs b/src/AngleSharp.Js.Tests/EcmaTests.cs index 58b68ed5..39ad976e 100644 --- a/src/AngleSharp.Js.Tests/EcmaTests.cs +++ b/src/AngleSharp.Js.Tests/EcmaTests.cs @@ -538,5 +538,31 @@ public async Task ModuleIsNotReadFromTheLocalDisk(String way) File.Delete(script); } } + + [TestCase("", "/dir/a.js")] + [TestCase("", "/a.js")] + [TestCase("", "/a.js")] + [TestCase("", "/dir/a.js")] + [TestCase("", "/other/a.js")] + [TestCase("", "/dir/a.js")] + public async Task RelativeModuleIsLoadedFromTheAddressOfWhatImportsIt(String page, String path) + { + // Only the expected address serves the module; any other one answers with nothing. + var config = Configuration.Default + .WithJs() + .WithEventLoop() + .With(new MockHttpClientRequester(new Dictionary + { + { "/dir/mod.js", "import x from './a.js'; show(x);" }, + { path, "export default 'ok';" }, + })) + .WithDefaultLoader(new LoaderOptions { IsResourceLoadingEnabled = true }); + var html = "
pending
" + page; + var document = await BrowsingContext.New(config) + .OpenAsync(r => r.Content(html).Address("https://example.com/dir/page.html")) + .WhenStable() + .ConfigureAwait(false); + Assert.AreEqual("ok", document.GetElementById("test").TextContent); + } } } diff --git a/src/AngleSharp.Js/EngineInstance.cs b/src/AngleSharp.Js/EngineInstance.cs index 96ec0628..b63f10d7 100644 --- a/src/AngleSharp.Js/EngineInstance.cs +++ b/src/AngleSharp.Js/EngineInstance.cs @@ -49,7 +49,7 @@ public EngineInstance(IWindow window, IDictionary assignments, I { _importMap = new JsImportMap(); var engineOptions = new Options(); - engineOptions.EnableModules(new JsModuleLoader(this, window.Document, false)); + engineOptions.EnableModules(new JsModuleLoader(this, window.Document)); // The handler uses the caches initialized below. The creator must return the // engine before DOM objects are wrapped or page scripts are evaluated. engineOptions.SetWrapObjectHandler(WrapObject); diff --git a/src/AngleSharp.Js/JsModuleLoader.cs b/src/AngleSharp.Js/JsModuleLoader.cs index acc0154b..a200cbf3 100644 --- a/src/AngleSharp.Js/JsModuleLoader.cs +++ b/src/AngleSharp.Js/JsModuleLoader.cs @@ -9,23 +9,28 @@ namespace AngleSharp.Js using System; using System.Linq; - internal class JsModuleLoader : DefaultModuleLoader + internal class JsModuleLoader : ModuleLoader { private readonly EngineInstance _instance; private readonly IResourceLoader _resourceLoader; private readonly IElement _scriptElement; - private readonly string _documentUrl; + private readonly IDocument _document; - public JsModuleLoader(EngineInstance instance, IDocument document, bool restrictToBasePath = true) : base (document.Url, restrictToBasePath) + public JsModuleLoader(EngineInstance instance, IDocument document) { _instance = instance; _resourceLoader = document.Context.GetService(); _scriptElement = document.CreateElement(TagNames.Script); - _documentUrl = document.Url; + _document = document; } + // The key of a resolved module is its address, and its Uri stays null: Jint gives a module + // loaded with a Uri only the path of it as location, so the modules it imports would lose + // the origin. With the key as location, they are resolved against the full address. public override ResolvedSpecifier Resolve(string referencingModuleLocation, ModuleRequest moduleRequest) { + var specifier = moduleRequest.Specifier; + if (referencingModuleLocation != null && _instance.ImportMap.Scopes.Count > 0) { foreach (var scopePath in _instance.ImportMap.Scopes.Keys.OrderByDescending(k => k.Length)) @@ -34,55 +39,38 @@ public override ResolvedSpecifier Resolve(string referencingModuleLocation, Modu { var scopeImports = _instance.ImportMap.Scopes[scopePath]; - if (scopeImports.TryGetValue(moduleRequest.Specifier, out var scopeModuleUrl)) + if (scopeImports.TryGetValue(specifier, out var scopeModuleUrl)) { - if (!scopeModuleUrl.IsAbsoluteUri) - { - scopeModuleUrl = new Uri(new Uri(_documentUrl), scopeModuleUrl); - } - - return new ResolvedSpecifier( - moduleRequest, - moduleRequest.Specifier, - scopeModuleUrl, - SpecifierType.RelativeOrAbsolute); + return Resolved(moduleRequest, new Url(GetDocumentBaseUrl(), scopeModuleUrl.OriginalString)); } } } } - if (_instance.ImportMap.Imports.TryGetValue(moduleRequest.Specifier, out var moduleUrl)) + if (_instance.ImportMap.Imports.TryGetValue(specifier, out var moduleUrl)) { - if (!moduleUrl.IsAbsoluteUri) - { - moduleUrl = new Uri(new Uri(_documentUrl), moduleUrl); - } - - return new ResolvedSpecifier( - moduleRequest, - moduleRequest.Specifier, - moduleUrl, - SpecifierType.RelativeOrAbsolute); + return Resolved(moduleRequest, new Url(GetDocumentBaseUrl(), moduleUrl.OriginalString)); } - // Before passing to the base default module loader, make sure any relative paths are prepended with a dot - // as Jint will otherwise resolve them as absolute file paths when running on Linux - if (moduleRequest.Specifier.StartsWith("/")) + if (specifier.StartsWith("/") || specifier.StartsWith("./") || specifier.StartsWith("../")) { - moduleRequest = new ModuleRequest($".{moduleRequest.Specifier}", moduleRequest.Attributes); + return Resolved(moduleRequest, new Url(GetBaseUrl(referencingModuleLocation), specifier)); } - if (referencingModuleLocation?.StartsWith("/") == true) + var url = new Url(specifier); + + if (!url.IsInvalid && url.IsAbsolute) { - referencingModuleLocation = $".{referencingModuleLocation}"; + return Resolved(moduleRequest, url); } - - return base.Resolve(referencingModuleLocation, moduleRequest); + + // A bare name, which no address belongs to. The module scripts of the document are + // added to the engine under such a name, so it must stay what it is to find them. + return new ResolvedSpecifier(moduleRequest, specifier, null, SpecifierType.Bare); } // Every module comes through AngleSharp's resource loader, whatever its scheme, so the - // requesters the host registered decide what a page can reach. The base implementations - // of both methods would read file: URLs straight from the local disk, for any page. + // requesters the host registered decide what a page can reach. protected override string LoadModuleContents(Engine engine, ResolvedSpecifier resolved) { return FetchModule(GetModuleUrl(resolved)); @@ -93,17 +81,44 @@ protected override Byte[] LoadModuleContentsAsBytes(Engine engine, ResolvedSpeci return FetchModuleBytes(GetModuleUrl(resolved)); } - private static Uri GetModuleUrl(ResolvedSpecifier resolved) + private static ResolvedSpecifier Resolved(ModuleRequest moduleRequest, Url url) + { + return new ResolvedSpecifier(moduleRequest, url.Href, null, SpecifierType.RelativeOrAbsolute); + } + + // A module resolves against the address of what imports it, which is a module or a script + // that was loaded; one written into the document resolves against the base URL of it. + private Url GetBaseUrl(string referencingModuleLocation) + { + if (referencingModuleLocation != null) + { + var url = new Url(referencingModuleLocation); + + if (!url.IsInvalid && url.IsAbsolute) + { + return url; + } + } + + return GetDocumentBaseUrl(); + } + + private Url GetDocumentBaseUrl() + { + return _document.BaseUrl ?? new Url(_document.Url); + } + + private static Url GetModuleUrl(ResolvedSpecifier resolved) { - if (resolved.Uri == null) + if (resolved.Type != SpecifierType.RelativeOrAbsolute) { throw new NotSupportedException($"The module '{resolved.ModuleRequest.Specifier}' could not be resolved to a URL."); } - return resolved.Uri; + return new Url(resolved.Key); } - private string FetchModule(Uri moduleUrl) + private string FetchModule(Url moduleUrl) { if (_resourceLoader == null) { @@ -122,7 +137,7 @@ private string FetchModule(Uri moduleUrl) return content; } - private Byte[] FetchModuleBytes(Uri moduleUrl) + private Byte[] FetchModuleBytes(Url moduleUrl) { if (_resourceLoader == null) { @@ -138,12 +153,11 @@ private Byte[] FetchModuleBytes(Uri moduleUrl) } } - private IResponse Fetch(Uri moduleUrl) + private IResponse Fetch(Url moduleUrl) { - var importUrl = Url.Convert(moduleUrl); - var request = new ResourceRequest(_scriptElement, importUrl); + var request = new ResourceRequest(_scriptElement, moduleUrl); var response = _resourceLoader.FetchAsync(request).Task.Result; - return response ?? throw new InvalidOperationException($"No requester could load the module '{importUrl.Href}'."); + return response ?? throw new InvalidOperationException($"No requester could load the module '{moduleUrl.Href}'."); } } } diff --git a/src/AngleSharp.Js/JsScriptingService.cs b/src/AngleSharp.Js/JsScriptingService.cs index 0501203c..f7c89cdd 100644 --- a/src/AngleSharp.Js/JsScriptingService.cs +++ b/src/AngleSharp.Js/JsScriptingService.cs @@ -100,10 +100,12 @@ public async Task EvaluateScriptAsync(IResponse response, ScriptOptions options, var content = await reader.ReadToEndAsync().ConfigureAwait(false); var type = options.Element?.Type; // A module is imported by this, so it has to tell it apart from other modules: - // one written into the document would have the address of the document. + // one written into the document would have the address of the document. One that + // was loaded has its address, which is what the modules it imports resolve against. var isModule = type.Isi("module"); - var sourceUrl = isModule ? options.Element?.Source : response.Address?.Href ?? options.Element?.Source; - var position = isModule || !String.IsNullOrEmpty(options.Element?.Source) ? default : FindInDocument(options.Document, content); + var isInline = String.IsNullOrEmpty(options.Element?.Source); + var sourceUrl = isModule && isInline ? null : response.Address?.Href ?? options.Element?.Source; + var position = isModule || !isInline ? default : FindInDocument(options.Document, content); await options.EventLoop.EnqueueAsync(_ => { var document = options.Document ?? throw new ArgumentNullException(nameof(options)); From 03eef8fa212510945e3bd6020feb17099b16f649 Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Thu, 5 Nov 2026 11:22:50 +0100 Subject: [PATCH 08/13] Enhanced for DOM compatibility --- .github/workflows/ci.yml | 8 +- CHANGELOG.md | 35 ++ src/AngleSharp.Js.Tests/CallbackTests.cs | 214 +++++++++ .../CustomElementsTests.cs | 438 ++++++++++++++++++ .../DeferredConstructorTests.cs | 18 +- src/AngleSharp.Js.Tests/DomExceptionTests.cs | 102 ++++ src/AngleSharp.Js.Tests/IterableTests.cs | 235 ++++++++++ .../JavascriptErrorTests.cs | 16 + .../MicrotaskCheckpointTests.cs | 96 ++++ src/AngleSharp.Js.Tests/ModuleTests.cs | 207 +++++++++ src/AngleSharp.Js.Tests/PerformanceTests.cs | 81 ++++ src/AngleSharp.Js.Tests/ScriptEvalTests.cs | 2 +- src/AngleSharp.Js.Tests/TextEncodingTests.cs | 135 ++++++ src/AngleSharp.Js.Tests/WebIdlMemberTests.cs | 139 ++++++ src/AngleSharp.Js.Tests/WorkerTests.cs | 109 +++++ src/AngleSharp.Js/Cache/DomShape.cs | 10 +- src/AngleSharp.Js/Cache/DomShapeCache.cs | 125 ++++- src/AngleSharp.Js/Cache/PrototypeTypeCache.cs | 5 +- src/AngleSharp.Js/Dom/Crypto.cs | 63 +++ src/AngleSharp.Js/Dom/CustomElementHost.cs | 151 ++++++ src/AngleSharp.Js/Dom/DomExceptions.cs | 112 +++++ src/AngleSharp.Js/Dom/DomParser.cs | 4 +- .../Dom/EventAttributeObserver.cs | 35 +- src/AngleSharp.Js/Dom/FrameRequestCallback.cs | 40 ++ src/AngleSharp.Js/Dom/Performance.cs | 45 +- src/AngleSharp.Js/Dom/PerformanceObserver.cs | 188 ++++++++ src/AngleSharp.Js/Dom/ScriptValueCloner.cs | 16 + src/AngleSharp.Js/Dom/StructuredClone.cs | 7 + src/AngleSharp.Js/Dom/TextEncoder.cs | 218 +++++++++ src/AngleSharp.Js/Dom/WindowExtensions.cs | 88 ++++ src/AngleSharp.Js/Dom/Worker.cs | 76 ++- src/AngleSharp.Js/EngineInstance.cs | 325 ++++++++++++- src/AngleSharp.Js/Extensions/DomDelegates.cs | 123 ++++- .../Extensions/EngineExtensions.cs | 47 +- .../Extensions/JsValueExtensions.cs | 10 +- .../Extensions/ReflectionExtensions.cs | 13 + .../JsConfigurationExtensions.cs | 5 +- src/AngleSharp.Js/JsHost.cs | 51 ++ src/AngleSharp.Js/JsModuleLoader.cs | 54 ++- src/AngleSharp.Js/JsScriptingService.cs | 22 +- .../Proxies/DomConstructorInstance.cs | 45 +- .../Proxies/DomEventDefinition.cs | 49 +- src/Directory.Build.props | 2 +- 43 files changed, 3628 insertions(+), 136 deletions(-) create mode 100644 src/AngleSharp.Js.Tests/CustomElementsTests.cs create mode 100644 src/AngleSharp.Js.Tests/DomExceptionTests.cs create mode 100644 src/AngleSharp.Js.Tests/IterableTests.cs create mode 100644 src/AngleSharp.Js.Tests/MicrotaskCheckpointTests.cs create mode 100644 src/AngleSharp.Js.Tests/ModuleTests.cs create mode 100644 src/AngleSharp.Js.Tests/TextEncodingTests.cs create mode 100644 src/AngleSharp.Js.Tests/WebIdlMemberTests.cs create mode 100644 src/AngleSharp.Js/Dom/Crypto.cs create mode 100644 src/AngleSharp.Js/Dom/CustomElementHost.cs create mode 100644 src/AngleSharp.Js/Dom/DomExceptions.cs create mode 100644 src/AngleSharp.Js/Dom/PerformanceObserver.cs create mode 100644 src/AngleSharp.Js/Dom/ScriptValueCloner.cs create mode 100644 src/AngleSharp.Js/Dom/TextEncoder.cs create mode 100644 src/AngleSharp.Js/JsHost.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 10651afd..7d85a75e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,7 +55,7 @@ jobs: 10.0.x - name: Build - run: ./build.sh -AngleSharpVersion 1.8.0 + run: ./build.sh -AngleSharpVersion 1.10.0 windows: runs-on: windows-latest @@ -85,9 +85,9 @@ jobs: NUGET_API_KEY: ${{ steps.nuget_login.outputs.NUGET_API_KEY }} run: | if ($env:GITHUB_REF -eq "refs/heads/main") { - .\build.ps1 -Target Publish -AngleSharpVersion 1.8.0 + .\build.ps1 -Target Publish -AngleSharpVersion 1.10.0 } elseif ($env:GITHUB_REF -eq "refs/heads/devel") { - .\build.ps1 -Target PrePublish -AngleSharpVersion 1.8.0 + .\build.ps1 -Target PrePublish -AngleSharpVersion 1.10.0 } else { - .\build.ps1 -AngleSharpVersion 1.8.0 + .\build.ps1 -AngleSharpVersion 1.10.0 } diff --git a/CHANGELOG.md b/CHANGELOG.md index c1836abd..98deefa0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ Released on Friday, November 6 2026. +- Fixed text given to `append`, `prepend` and the like failing instead of becoming a text node +- Fixed a class deriving from a type of the DOM not having its instances on its prototype chain +- Fixed an object with `handleEvent` not being accepted as an event listener, or one with `acceptNode` as a filter of a `TreeWalker` or `NodeIterator` +- Fixed a function not being accepted as a callback whose result is waited for, e.g. of `navigator.locks.request`: it runs on the event loop and is followed to its promise +- Fixed promise jobs not running after a timer, a listener or any other callback of the host: they waited for something else to run them, so `await` could hang +- Fixed a `DOMException` with a name having no code (e.g. `NotAllowedError`) being named `Error` for script +- Fixed a date from the DOM not being a `Date` for script, and bytes not surviving a structured clone +- Fixed a function not being accepted as a callback with a result, e.g. as a filter of a `TreeWalker` +- Fixed a worker that failed to start throwing from `postMessage` instead of firing an `error` event +- Fixed collections from attributes, e.g. `document.links` or `element.children`, not being iterable +- Fixed `classList` not being iterable and having no `forEach`, `entries`, `keys` and `values` +- Fixed a member of an options dictionary without a default becoming `undefined` in text +- Fixed members of the DOM being read-only and copied onto every prototype, so polyfills could neither replace them nor reach derived types (e.g. patching `Element.prototype.querySelector`): each is now on the prototype of the interface declaring it, as WebIDL says (writable, enumerable and configurable) +- Fixed interface objects of the window being read-only and enumerable: `window.Node = ...` now also changes `Node`, as WebIDL says - Fixed bound functions and callable proxies not being accepted as callbacks (e.g. `setTimeout(f.bind(x))`, `addEventListener('click', f.bind(x))`, `requestAnimationFrame(f.bind(x))`, `onclick`), failing with `Could not find corresponding cast target` - Fixed `removeEventListener` not removing listeners, as a function was turned into another listener each time - Fixed errors of event listeners not reaching the `error` listeners of the window; an error thrown by such a listener goes to the browsing context @@ -12,6 +26,11 @@ Released on Friday, November 6 2026. - Fixed `error` of an `ErrorEvent` being a .NET exception instead of what script threw - Fixed an `onclick` and the like throwing taking down the process and skipping the other listeners, as it was not reported like a listener - Fixed a `TypeError` or the like thrown for script by a member of the DOM becoming a plain `Error` +- Fixed modules being known by the path of their address, so errors didn't tell the whole address, and imports of an imported module were looked for from the wrong place +- Fixed a module written into a document telling lines and columns of its own, and importing from somewhere other than the base address of the document +- Fixed an error of a module being reported twice +- Fixed an event handler (e.g. `onclick`) returning `false` not cancelling the event +- Fixed an event handler attribute being added on top of the previous one when it changed, staying when it was removed, and throwing a syntax error at whoever set the attribute instead of reporting it - Fixed only the first of several methods or constructors of the same name being callable (e.g. `new URLSearchParams('a=1')`, `register(url, options)`); the one fitting the arguments is called - Fixed static members not being reachable (e.g. `DOMRect.fromRect`); they are on the constructor and may take the window as first parameter - Fixed members of classes marked with `DomExposed` ending up on what they are exposed to (e.g. the members of a constructor on `window`); only extension methods do @@ -24,6 +43,22 @@ Released on Friday, November 6 2026. - Fixed WebIDL stringifiers (`URL`, `URLSearchParams`, `Location`, `Range`, `DOMTokenList`, `DOMMatrix`) answering `String(x)` with `[object X]`; a type overriding `ToString` has a `toString` - Fixed sandboxed browsing contexts without `allow-scripts` running scripts and event handler attributes - Fixed `postMessage` ignoring `targetOrigin`, giving it as the origin of the event, turning objects into strings, and not having a `source`; messages are copied by the structured clone algorithm, and the options form is supported +- Added `clearResourceTimings` and `setResourceTimingBufferSize` to `performance` +- Added `{ type: 'module' }` and `onerror` to `Worker`, with imports being loaded by the worker +- Added `IScriptValueCloner`, so that values of script can be kept, e.g. by IndexedDB +- Added `structuredClone`, `origin`, `isSecureContext` and `length` to the window +- Added `DOMException`, which is what the DOM now throws for scripts: an `Error` with the `name` and `code` of the standard, also available as a constructor and with its constants +- Added `TextEncoder` and `TextDecoder` +- Added the iterator of a type giving `entries` (WebIDL `iterable`), so that e.g. `for (const [k, v] of new URLSearchParams(s))` works +- Added overloads brought by extensions for the methods of the DOM, as `addEventListener` with options has them +- Added `crypto` with `getRandomValues` and `randomUUID`, but without `subtle` +- Added `requestIdleCallback` and `cancelIdleCallback` +- Fixed `DOMParser` replacing the document of the page with what it parsed, and running its scripts +- Added `PerformanceObserver`, for the marks and measures of `performance` +- Added custom elements, run for the registry of AngleSharp: classes deriving from `HTMLElement` or another element, with their callbacks running when the call of a script returns (requires AngleSharp 1.10) +- Fixed classes deriving from a DOM type taking every object of that type for an instance of them +- Added what a promise rejects with being a `DOMException` or what was thrown, if the task behind it failed with one +- Added `import.meta.url` to modules; without it, e.g., webpack stopped with `Automatic publicPath is not supported in this browser` - Added `ArraySegment` being given as a `Uint8Array` - Added classes with implicit conversions standing for a WebIDL union, e.g. of a string and a sequence of strings, which fixes such conversions failing; an implicit conversion from such a class is what a script receives for it - Added the same-origin policy: script gets `null` for a node of another origin, and for its window a `CrossOriginWindow` with only `window`, `self`, `frames`, `parent`, `top`, `closed`, `length`, `location` (only to be set, and only for frames inside the own document), `postMessage`, `focus`, `blur` and `close` diff --git a/src/AngleSharp.Js.Tests/CallbackTests.cs b/src/AngleSharp.Js.Tests/CallbackTests.cs index 32109683..403ecb80 100644 --- a/src/AngleSharp.Js.Tests/CallbackTests.cs +++ b/src/AngleSharp.Js.Tests/CallbackTests.cs @@ -102,6 +102,220 @@ public async Task EventHandlerPropertyIgnoresWhatIsNotCallable() Assert.AreEqual("null", document.Body.GetAttribute("data-value")); } + private static async Task Dispatched(String setup) + { + var document = await OpenAsync($@" + var button = document.getElementById('b'); + {setup} + var ev = new Event('click', {{ cancelable: true }}); + button.dispatchEvent(ev); + document.title = String(ev.defaultPrevented);"); + return document.Title; + } + + [Test] + public async Task EventHandlerPropertyReturningFalseCancelsTheEvent() + { + Assert.AreEqual("true", await Dispatched("button.onclick = function () { return false; };")); + } + + [Test] + public async Task EventHandlerAttributeReturningFalseCancelsTheEvent() + { + Assert.AreEqual("true", await Dispatched("button.setAttribute('onclick', 'return false');")); + } + + [TestCase("return true;")] + [TestCase("return 0;")] + [TestCase("")] + public async Task EventHandlerPropertyReturningAnythingElseDoesNotCancelTheEvent(String body) + { + Assert.AreEqual("false", await Dispatched($"button.onclick = function () {{ {body} }};")); + } + + [Test] + public async Task EventListenerReturningFalseDoesNotCancelTheEvent() + { + Assert.AreEqual("false", await Dispatched("button.addEventListener('click', function () { return false; });")); + } + + [Test] + public async Task BoundEventHandlerReturningFalseCancelsTheEvent() + { + Assert.AreEqual("true", await Dispatched("button.onclick = function () { return false; }.bind(null);")); + } + + [Test] + public async Task ChangingAnEventHandlerAttributeReplacesTheHandler() + { + var document = await OpenAsync(@" + var calls = []; + var button = document.getElementById('b'); + button.setAttribute('onclick', 'calls.push(1)'); + button.setAttribute('onclick', 'calls.push(2)'); + button.dispatchEvent(new Event('click')); + document.title = calls.join();"); + + Assert.AreEqual("2", document.Title); + } + + [Test] + public async Task RemovingAnEventHandlerAttributeRemovesTheHandler() + { + var document = await OpenAsync(@" + var calls = 0; + var button = document.getElementById('b'); + button.setAttribute('onclick', 'calls++'); + button.removeAttribute('onclick'); + button.dispatchEvent(new Event('click')); + document.title = String(calls);"); + + Assert.AreEqual("0", document.Title); + } + + [Test] + public async Task EventHandlerAttributeWithASyntaxErrorIsReported() + { + var document = await OpenAsync(@" + window.onerror = function (message) { document.title = 'reported'; }; + var button = document.getElementById('b'); + button.setAttribute('onclick', 'function ('); + document.body.setAttribute('data-after', 'yes'); + button.dispatchEvent(new Event('click'));"); + + Assert.AreEqual("reported", document.Title); + Assert.AreEqual("yes", document.Body.GetAttribute("data-after")); + } + + [Test] + public async Task IdleCallbackGetsItsDeadline() + { + var document = await OpenAsync(@" + var called = false; + requestIdleCallback(function (deadline) { + document.title = [deadline.didTimeout, deadline.timeRemaining() > 0, deadline.timeRemaining() <= 50].join(); + }, { timeout: 100 });"); + + await WaitForTitle(document, "false,true,true"); + } + + [Test] + public async Task CancelledIdleCallbackIsNotCalled() + { + var document = await OpenAsync(@" + var handle = requestIdleCallback(function () { document.title = 'wrong'; }); + cancelIdleCallback(handle); + setTimeout(function () { document.title = 'fine'; }, 30);"); + + await WaitForTitle(document, "fine"); + } + + [Test] + public async Task DomParserLeavesThePageAlone() + { + var config = Configuration.Default.WithJs().WithEventLoop(); + var context = BrowsingContext.New(config); + var document = await context.OpenAsync(m => m.Content(@"

page

")); + + Assert.AreSame(document, context.Active); + Assert.AreEqual("other,false,page", document.Title); + Assert.IsNotNull(document.GetElementById("mine")); + } + + [Test] + public async Task DomParserDoesNotRunWhatItParses() + { + var document = await OpenAsync(@" + new DOMParser().parseFromString('"); + return document.Title; + } + + [Test] + public async Task RegistryIsOnTheWindow() + { + Assert.AreEqual("object,true,function", await Eval("return [typeof customElements, window.customElements === customElements, typeof customElements.define].join();")); + } + + [Test] + public async Task ClassIsDefinedAndFound() + { + Assert.AreEqual("true,my-el,true", await Eval(@" + class MyEl extends HTMLElement {} + customElements.define('my-el', MyEl); + return [customElements.get('my-el') === MyEl, customElements.getName(MyEl), customElements.get('other-el') === undefined].join();")); + } + + [Test] + public async Task CreatedElementIsAnInstanceOfItsClass() + { + Assert.AreEqual("true,true,true,MY-EL,hello", await Eval(@" + class MyEl extends HTMLElement { + constructor() { super(); this.greeting = 'hello'; } + greet() { return this.greeting; } + } + customElements.define('my-el', MyEl); + var el = document.createElement('my-el'); + return [el instanceof MyEl, el instanceof HTMLElement, el instanceof Element, el.tagName, el.greet()].join();")); + } + + [Test] + public async Task ElementCreatedWithNewIsTheElementOfTheDefinition() + { + Assert.AreEqual("MY-EL,true,x", await Eval(@" + class MyEl extends HTMLElement {} + customElements.define('my-el', MyEl); + var el = new MyEl(); + el.setAttribute('a', 'x'); + document.body.appendChild(el); + return [el.tagName, el instanceof MyEl, document.querySelector('my-el').getAttribute('a')].join();")); + } + + [Test] + public async Task WrapperStaysTheSameObject() + { + Assert.AreEqual("true", await Eval(@" + class MyEl extends HTMLElement {} + customElements.define('my-el', MyEl); + var el = new MyEl(); + document.body.appendChild(el); + return document.querySelector('my-el') === el;")); + } + + [Test] + public async Task ExistingElementsAreUpgradedWhenDefined() + { + Assert.AreEqual("false,true,upgraded", await Eval(@" + var el = document.getElementById('a'); + var before = el instanceof HTMLUnknownElement && el.state === 'upgraded'; + class MyEl extends HTMLElement { constructor() { super(); this.state = 'upgraded'; } } + customElements.define('my-el', MyEl); + return [before, el instanceof MyEl, el.state].join();", "")); + } + + [Test] + public async Task ParsedElementsAfterTheDefinitionAreUpgraded() + { + var document = await OpenAsync(@""); + + Assert.AreEqual("constructed a,connected a true", document.Title); + } + + [Test] + public async Task CallbacksAreCalledWithTheElementAsThis() + { + Assert.AreEqual("connected:true,attr:color::red,disconnected:true", await Eval(@" + var log = []; + class MyEl extends HTMLElement { + static get observedAttributes() { return ['color']; } + connectedCallback() { log.push('connected:' + (this instanceof MyEl)); } + disconnectedCallback() { log.push('disconnected:' + (this instanceof MyEl)); } + attributeChangedCallback(name, oldValue, newValue) { log.push('attr:' + name + ':' + (oldValue === null ? '' : oldValue) + ':' + newValue); } + } + customElements.define('my-el', MyEl); + var el = document.createElement('my-el'); + document.body.appendChild(el); + el.setAttribute('color', 'red'); + el.setAttribute('other', 'x'); + el.remove(); + return log.join();")); + } + + [Test] + public async Task ObservedAttributesMayBeAStaticField() + { + Assert.AreEqual("a", await Eval(@" + var log = []; + class MyEl extends HTMLElement { + static observedAttributes = ['a']; + attributeChangedCallback(name) { log.push(name); } + } + customElements.define('my-el', MyEl); + var el = document.createElement('my-el'); + el.setAttribute('b', '1'); + el.setAttribute('a', '1'); + return log.join();")); + } + + [Test] + public async Task ClassCanExtendACustomElementClass() + { + Assert.AreEqual("base,true,true", await Eval(@" + class Base extends HTMLElement { who() { return 'base'; } } + class Derived extends Base {} + customElements.define('my-derived', Derived); + var el = new Derived(); + return [el.who(), el instanceof Base, el instanceof Derived].join();")); + } + + [Test] + public async Task ClassCanExtendABuiltInElement() + { + Assert.AreEqual("BUTTON,true,true,my-button", await Eval(@" + class MyButton extends HTMLButtonElement {} + customElements.define('my-button', MyButton, { extends: 'button' }); + var el = new MyButton(); + return [el.tagName, el instanceof MyButton, el instanceof HTMLButtonElement, el.outerHTML.indexOf('is=""my-button""') > 0 ? 'my-button' : el.outerHTML].join();")); + } + + [Test] + public async Task ElementCanHaveAShadowRoot() + { + Assert.AreEqual("inner", await Eval(@" + class MyEl extends HTMLElement { + constructor() { super(); this.attachShadow({ mode: 'open' }).innerHTML = 'inner'; } + } + customElements.define('my-el', MyEl); + return new MyEl().shadowRoot.querySelector('span').textContent;")); + } + + [Test] + public async Task ClassNotDefinedCannotBeCreated() + { + Assert.AreEqual("TypeError", await Eval(@" + class MyEl extends HTMLElement {} + try { new MyEl(); return 'no error'; } catch (e) { return e.name; }")); + } + + [Test] + public async Task HtmlElementItselfCannotBeCreated() + { + Assert.AreEqual("TypeError", await Eval("try { new HTMLElement(); return 'no error'; } catch (e) { return e.name; }")); + } + + [TestCase("customElements.define('nohyphen', class extends HTMLElement {})", "SyntaxError")] + [TestCase("customElements.define('my-el', class extends HTMLElement {}); customElements.define('my-el', class extends HTMLElement {})", "NotSupportedError")] + [TestCase("customElements.define('my-el', function () {}.bind(null))", "TypeError")] + [TestCase("customElements.define('my-el', 42)", "TypeError")] + [TestCase("customElements.define('my-el', class extends HTMLElement { get connectedCallback() { return 1; } })", "TypeError")] + public async Task WhatCannotBeDefinedIsRefused(String statement, String expected) + { + Assert.AreEqual(expected, await Eval($"try {{ {statement}; return 'no error'; }} catch (e) {{ return e.name; }}")); + } + + [Test] + public async Task WhenDefinedGivesTheClass() + { + var document = await OpenAsync(@""); + + for (var i = 0; i < 200 && document.Title.Length == 0; i++) + { + await Task.Delay(10); + } + + Assert.AreEqual("true", document.Title); + } + + [Test] + public async Task ConstructorThrowingIsReportedAndLeavesTheElement() + { + var context = BrowsingContext.New(Configuration.Default.WithJs().WithEventLoop()); + var errors = context.CollectErrors(); + var document = await context.OpenAsync(m => m.Content(@"")); + + Assert.AreEqual("null", document.Title); + Assert.AreEqual(1, errors.Count); + StringAssert.Contains("broken", errors[0].Message); + } + + [Test] + public async Task CallbackThrowingIsReported() + { + var context = BrowsingContext.New(Configuration.Default.WithJs().WithEventLoop()); + var errors = context.CollectErrors(); + var document = await context.OpenAsync(m => m.Content(@"")); + + Assert.AreEqual("went on", document.Title); + Assert.AreEqual(1, errors.Count); + } + + [Test] + public async Task ConstructorNotCallingSuperIsReported() + { + var context = BrowsingContext.New(Configuration.Default.WithJs().WithEventLoop()); + var errors = context.CollectErrors(); + await context.OpenAsync(m => m.Content(@"")); + + Assert.AreEqual(1, errors.Count); + } + + [Test] + public async Task ConstructorMayCreateOtherCustomElements() + { + Assert.AreEqual("MY-INNER,true", await Eval(@" + class Inner extends HTMLElement {} + class Outer extends HTMLElement { constructor() { super(); this.inner = new Inner(); } } + customElements.define('my-inner', Inner); + customElements.define('my-outer', Outer); + var outer = new Outer(); + return [outer.inner.tagName, outer.inner instanceof Inner].join();")); + } + + [Test] + public async Task ReflectConstructWorksAsForOldClasses() + { + Assert.AreEqual("true", await Eval(@" + function MyEl() { return Reflect.construct(HTMLElement, [], MyEl); } + MyEl.prototype = Object.create(HTMLElement.prototype); + MyEl.prototype.constructor = MyEl; + customElements.define('my-el', MyEl); + return document.createElement('my-el') instanceof MyEl;")); + } + + [Test] + public async Task DefinedPseudoClassFollowsTheDefinition() + { + Assert.AreEqual("0,1", await Eval(@" + var before = document.querySelectorAll('my-el:defined').length; + customElements.define('my-el', class extends HTMLElement {}); + return [before, document.querySelectorAll('my-el:defined').length].join();", "")); + } + + [Test] + public async Task CallbacksSeeWhatTheCallOfTheScriptDidCompletely() + { + Assert.AreEqual("a:my-b,b:", await Eval(@" + var log = []; + class Item extends HTMLElement { + connectedCallback() { log.push(this.id + ':' + (this.nextElementSibling ? this.nextElementSibling.localName : '')); } + } + customElements.define('my-a', class extends Item {}); + customElements.define('my-b', class extends Item {}); + var parent = document.createElement('div'); + document.body.appendChild(parent); + parent.innerHTML = ''; + return log.join();")); + } + + [Test] + public async Task CallbacksAreDoneWhenTheCallOfTheScriptReturns() + { + Assert.AreEqual("setting,changed,set", await Eval(@" + var log = []; + class MyEl extends HTMLElement { + static observedAttributes = ['a']; + attributeChangedCallback() { log.push('changed'); } + } + customElements.define('my-el', MyEl); + var el = new MyEl(); + log.push('setting'); + el.setAttribute('a', '1'); + log.push('set'); + return log.join();")); + } + + [Test] + public async Task AppendTellsAfterAllOfItIsIn() + { + Assert.AreEqual("2,2", await Eval(@" + var log = []; + class MyEl extends HTMLElement { connectedCallback() { log.push(this.parentNode.childElementCount); } } + customElements.define('my-el', MyEl); + var parent = document.createElement('div'); + document.body.appendChild(parent); + parent.append(new MyEl(), new MyEl()); + return log.join();")); + } + + [Test] + public async Task CallbackChangingTheTreeWorksInTheMiddleOfAnotherCall() + { + Assert.AreEqual("1", await Eval(@" + class MyEl extends HTMLElement { + connectedCallback() { + var inner = document.createElement('span'); + this.appendChild(inner); + inner.remove(); + this.appendChild(document.createElement('b')); + } + } + customElements.define('my-el', MyEl); + var el = new MyEl(); + document.body.appendChild(el); + return el.childElementCount;")); + } + + [Test] + public async Task CreateElementTakesWhatItIs() + { + Assert.AreEqual("true,true,true", await Eval(@" + class MyButton extends HTMLButtonElement {} + customElements.define('my-button', MyButton, { extends: 'button' }); + var el = document.createElement('button', { is: 'my-button' }); + var plain = document.createElement('button'); + return [el instanceof MyButton, el.getAttribute('is') === null, !(plain instanceof MyButton)].join();")); + } + + [Test] + public async Task CreateElementKeepsWorkingWithoutOptions() + { + Assert.AreEqual("DIV,DIV", await Eval(@" + return [document.createElement('div').tagName, document.createElement('div', {}).tagName].join();")); + } + + [Test] + public async Task ClonedElementIsAnInstanceOfItsClass() + { + Assert.AreEqual("true,false,constructed", await Eval(@" + class MyEl extends HTMLElement { constructor() { super(); this.state = 'constructed'; } } + customElements.define('my-el', MyEl); + var el = new MyEl(); + var clone = el.cloneNode(); + return [clone instanceof MyEl, clone === el, clone.state].join();")); + } + + [Test] + public async Task AdoptedCallbackGetsTheDocuments() + { + Assert.AreEqual("true,true", await Eval(@" + var log = []; + class MyEl extends HTMLElement { adoptedCallback(oldDocument, newDocument) { log.push(oldDocument === document, newDocument === other); } } + customElements.define('my-el', MyEl); + var other = document.implementation.createHTMLDocument('other'); + var el = new MyEl(); + other.body.appendChild(el); + return log.join();")); + } + + [Test] + public async Task ThrowingMemberStillRunsTheReactionsOfItsCall() + { + Assert.AreEqual("connected,caught", await Eval(@" + var log = []; + class MyEl extends HTMLElement { connectedCallback() { log.push('connected'); } } + customElements.define('my-el', MyEl); + var el = new MyEl(); + document.body.appendChild(el); + try { document.body.appendChild(document.documentElement); } catch (e) { log.push('caught'); } + return log.join();")); + } + + [Test] + public async Task OtherElementsAreNoInstancesOfAClass() + { + Assert.AreEqual("true,false,false,true,true", await Eval(@" + class Base extends HTMLElement {} + class Derived extends Base {} + class Other extends HTMLElement {} + customElements.define('my-derived', Derived); + customElements.define('my-other', Other); + var el = new Derived(); + var div = document.createElement('div'); + return [el instanceof Base, el instanceof Other, div instanceof Base, div instanceof HTMLElement, el instanceof HTMLElement].join();")); + } + + [Test] + public async Task NodesCanBeReplacedWith() + { + Assert.AreEqual("B", await Eval(@" + var parent = document.createElement('div'); + var a = parent.appendChild(document.createElement('a')); + a.replaceWith(document.createElement('b')); + return parent.firstChild.tagName;")); + } + + [Test] + public async Task DomExceptionsOfTheRegistryAreRealOnes() + { + Assert.AreEqual("true", await Eval("try { customElements.define('x', class extends HTMLElement {}); } catch (e) { return e instanceof DOMException; }")); + } + } +} diff --git a/src/AngleSharp.Js.Tests/DeferredConstructorTests.cs b/src/AngleSharp.Js.Tests/DeferredConstructorTests.cs index 4cb91550..6ee5c9cd 100644 --- a/src/AngleSharp.Js.Tests/DeferredConstructorTests.cs +++ b/src/AngleSharp.Js.Tests/DeferredConstructorTests.cs @@ -39,24 +39,24 @@ public async Task UnreadConstructorIsStillOwnPropertyOfWindow() } [Test] - public async Task UnreadConstructorIsStillEnumerable() + public async Task UnreadConstructorIsStillListed() { - var result = await "String(Object.keys(window).indexOf('HTMLTableColElement') !== -1)".EvalScriptAsync(); + var result = await "String(Object.getOwnPropertyNames(window).indexOf('HTMLTableColElement') !== -1)".EvalScriptAsync(); Assert.AreEqual("true", result); } [Test] - public async Task UnreadConstructorIsStillFoundByForIn() + public async Task UnreadConstructorIsNotEnumerable() { var result = await "String((function () { for (var k in window) { if (k === 'HTMLTableColElement') { return true; } } return false; })())".EvalScriptAsync(); - Assert.AreEqual("true", result); + Assert.AreEqual("false", result); } [Test] public async Task ConstructorKeepsItsAttributes() { var result = await "(function () { var d = Object.getOwnPropertyDescriptor(window, 'HTMLDivElement'); return d.writable + ',' + d.enumerable + ',' + d.configurable; })()".EvalScriptAsync(); - Assert.AreEqual("false,true,false", result); + Assert.AreEqual("true,false,true", result); } [Test] @@ -111,16 +111,16 @@ public async Task InstanceReportsTheSameConstructorTheWindowPublishes() } [Test] - public async Task ConstructorIsNotWritable() + public async Task ConstructorIsWritable() { - var result = await "String((function () { var before = HTMLDivElement; window.HTMLDivElement = 5; return window.HTMLDivElement === before; })())".EvalScriptAsync(); + var result = await "String((function () { window.HTMLDivElement = 5; return window.HTMLDivElement === 5 && HTMLDivElement === 5; })())".EvalScriptAsync(); Assert.AreEqual("true", result); } [Test] - public async Task ConstructorIsNotConfigurable() + public async Task ConstructorIsConfigurable() { - var result = await "String((delete window.HTMLDivElement) === false && typeof HTMLDivElement === 'function')".EvalScriptAsync(); + var result = await "String((delete window.HTMLDivElement) === true && typeof HTMLDivElement === 'undefined')".EvalScriptAsync(); Assert.AreEqual("true", result); } diff --git a/src/AngleSharp.Js.Tests/DomExceptionTests.cs b/src/AngleSharp.Js.Tests/DomExceptionTests.cs new file mode 100644 index 00000000..8ac12ee7 --- /dev/null +++ b/src/AngleSharp.Js.Tests/DomExceptionTests.cs @@ -0,0 +1,102 @@ +namespace AngleSharp.Js.Tests +{ + using NUnit.Framework; + using System; + using System.Threading.Tasks; + + /// + /// The errors of the DOM, which scripts tell apart by their name. + /// + [TestFixture] + public class DomExceptionTests + { + private static Task Eval(String body) => $"(function () {{ {body} }})()".EvalScriptAsync(); + + private static String Describe(String statement) => + $"try {{ {statement}; return 'no error'; }} catch (e) {{ return [e.name, e.code, e instanceof DOMException, e instanceof Error, e.message.length > 0].join(); }}"; + + [Test] + public async Task IsAvailableOnTheWindow() + { + Assert.AreEqual("function,true", await Eval("return [typeof DOMException, window.DOMException === DOMException].join();")); + } + + [Test] + public async Task CanBeCreatedByScripts() + { + var result = await Eval(@" + var e = new DOMException('what', 'NotFoundError'); + return [e.message, e.name, e.code, e instanceof DOMException, e instanceof Error, String(e), typeof e.stack].join();"); + + Assert.AreEqual("what,NotFoundError,8,true,true,NotFoundError: what,string", result); + } + + [Test] + public async Task ItsStackTellsWhoMadeIt() + { + var result = await Eval(@" + function makesIt() { return new DOMException('m'); } + return String(makesIt().stack.indexOf('makesIt') >= 0);"); + + Assert.AreEqual("true", result); + } + + [Test] + public async Task ItsStackTellsWhoCalledTheDom() + { + var result = await Eval(@" + function callsTheDom() { document.querySelector('!!'); } + try { callsTheDom(); return 'no error'; } catch (e) { return String(e.stack.indexOf('callsTheDom') >= 0); }"); + + Assert.AreEqual("true", result); + } + + [Test] + public async Task HasDefaults() + { + Assert.AreEqual(",Error,0", await Eval("var e = new DOMException(); return [e.message, e.name, e.code].join();")); + } + + [Test] + public async Task UnknownNameHasNoCode() + { + Assert.AreEqual("Custom,0", await Eval("var e = new DOMException('m', 'Custom'); return [e.name, e.code].join();")); + } + + [Test] + public async Task CannotBeCalledWithoutNew() + { + Assert.AreEqual("TypeError", await Eval("try { DOMException('m'); return 'no error'; } catch (e) { return e.name; }")); + } + + [Test] + public async Task HasItsConstants() + { + Assert.AreEqual("8,12,25,18", await Eval("return [DOMException.NOT_FOUND_ERR, DOMException.SYNTAX_ERR, DOMException.DATA_CLONE_ERR, new DOMException().SECURITY_ERR].join();")); + } + + [Test] + public async Task NameAndCodeCannotBeChangedByAssigning() + { + Assert.AreEqual("NotFoundError,8", await Eval("var e = new DOMException('m', 'NotFoundError'); e.name = 'x'; e.code = 1; return [e.name, e.code].join();")); + } + + [TestCase("document.querySelector('!!')", "SyntaxError,12,true,true,true")] + [TestCase("document.documentElement.appendChild(document.documentElement)", "HierarchyRequestError,3,true,true,true")] + [TestCase("atob('*')", "InvalidCharacterError,5,true,true,true")] + [TestCase("document.createElement('1 2')", "InvalidCharacterError,5,true,true,true")] + [TestCase("performance.measure('m', 'no such mark')", "SyntaxError,12,true,true,true")] + public async Task IsWhatTheDomThrows(String statement, String expected) + { + Assert.AreEqual(expected, await Eval(Describe(statement))); + } + + [Test] + public async Task OtherErrorsOfTheDomStayErrors() + { + var result = await Eval("try { performance.mark(); return 'no error'; } catch (e) { return [e.name, e instanceof DOMException].join(); }"); + + Assert.AreEqual("TypeError,false", result); + } + } +} diff --git a/src/AngleSharp.Js.Tests/IterableTests.cs b/src/AngleSharp.Js.Tests/IterableTests.cs new file mode 100644 index 00000000..b994b11b --- /dev/null +++ b/src/AngleSharp.Js.Tests/IterableTests.cs @@ -0,0 +1,235 @@ +namespace AngleSharp.Js.Tests +{ + using AngleSharp.Attributes; + using AngleSharp.Dom; + using NUnit.Framework; + using System; + using System.Threading.Tasks; + + [DomName("ProbeIterable")] + [DomExposed("Window")] + public sealed class ProbeIterable + { + [DomConstructor] + public ProbeIterable() + { + } + + [DomName("entries")] + public String[][] Entries() => new[] { new[] { "a", "1" }, new[] { "b", "2" } }; + } + + [DomExposed("Element")] + public static class ProbeCallbackExtensions + { + [DomName("probeWait")] + public static async Task ProbeWait(this IElement element, Func> callback) + { + await Task.Delay(1).ConfigureAwait(false); + return await callback.Invoke("given").ConfigureAwait(false); + } + + [DomName("probeDenied")] + public static void ProbeDenied(this IElement element) => throw new DomException("NotAllowedError"); + + [DomName("probeFailed")] + public static void ProbeFailed(this IElement element) => throw new DomException("something went wrong"); + + [DomName("probeClone")] + public static Object ProbeClone(this IElement element, Object value = null) => + element.Owner.Context.GetService().Clone(value); + + [DomName("probeUndefined")] + public static Object ProbeUndefined(this IElement element) => + element.Owner.Context.GetService().Undefined; + + [DomName("probeWaitPlain")] + public static async Task ProbeWaitPlain(this IElement element, Func callback) + { + await Task.Delay(1).ConfigureAwait(false); + await callback.Invoke("given").ConfigureAwait(false); + } + } + + /// + /// What gives entries (WebIDL iterable<K, V>) is iterated like them. + /// + [TestFixture] + public class IterableTests + { + private static async Task Eval(String script) + { + var cfg = Configuration.Default.WithJs().With(new ProbeService()); + var context = BrowsingContext.New(cfg); + var document = await context.OpenNewAsync(); + return document.ExecuteScript(script)?.ToString(); + } + + [TestCase("var r = []; for (const [k, v] of new ProbeIterable()) { r.push(k + '=' + v); } r.join()", "a=1,b=2")] + [TestCase("Array.from(new ProbeIterable()).length.toString()", "2")] + [TestCase("[...new ProbeIterable()][1][0]", "b")] + [TestCase("new Map(new ProbeIterable()).get('b')", "2")] + [TestCase("typeof ProbeIterable.prototype[Symbol.iterator]", "function")] + [TestCase("typeof new ProbeIterable()[Symbol.iterator]().next", "function")] + [TestCase("Object.keys(ProbeIterable.prototype).indexOf('entries') !== -1 ? 'yes' : 'no'", "yes")] + [TestCase("typeof Node.prototype[Symbol.iterator]", "undefined")] + public async Task IteratesAsEntries(String script, String expected) + { + Assert.AreEqual(expected, await Eval(script)); + } + + [TestCase("var d = document.createElement('div'); d.append('a', document.createElement('b'), 1); d.innerHTML", "a1")] + [TestCase("var d = document.createElement('div'); d.append(''); d.childNodes[0].nodeType.toString()", "3")] + [TestCase("var d = document.createElement('div'); d.innerHTML = ''; d.prepend('x'); d.innerHTML", "x")] + [TestCase("var d = document.createElement('div'); d.innerHTML = ''; d.replaceChildren('x', document.createElement('u')); d.innerHTML", "x")] + [TestCase("var d = document.createElement('div'); d.innerHTML = ''; d.replaceChildren(); d.childNodes.length.toString()", "0")] + [TestCase("var d = document.createElement('div'); d.innerHTML = ''; d.firstChild.before('x'); d.firstChild.after('y'); d.innerHTML", "xy")] + public async Task TextBecomesATextNode(String script, String expected) + { + Assert.AreEqual(expected, await Eval(script)); + } + + [TestCase("new EventTarget() instanceof EventTarget", "True")] + [TestCase("var r = 0, t = new EventTarget(); t.addEventListener('x', function () { r++; }); t.dispatchEvent(new Event('x')); r.toString()", "1")] + [TestCase("class T extends EventTarget { constructor() { super(); this.a = 1; } m() { return this.a + 1; } } var t = new T(); (t.m() + ',' + (t instanceof T) + ',' + (t instanceof EventTarget)).toString()", "2,true,true")] + [TestCase("class T extends EventTarget { #s = 5; get s() { return this.#s; } set(v) { this.#s = v; this.dispatchEvent(new Event('c')); } } var t = new T(), seen = 0; t.addEventListener('c', function () { seen = t.s; }); t.set(9); seen.toString()", "9")] + [TestCase("class E extends Event { constructor() { super('x'); } get k() { return 1; } } var t = new EventTarget(), r = 0; t.addEventListener('x', function (e) { r = e.k + (e instanceof E ? 1 : 0); }); t.dispatchEvent(new E()); r.toString()", "2")] + [TestCase("class U extends URLSearchParams { two() { return this.get('a') + '2'; } } new U('a=1').two()", "12")] + [TestCase("new URLSearchParams('a=1') instanceof URLSearchParams", "True")] + public async Task DerivedClassesAreOnTheChain(String script, String expected) + { + Assert.AreEqual(expected, await Eval(script)); + } + + [TestCase("var r = 0, d = document.createElement('div'), l = { handleEvent: function () { r++; } }; d.addEventListener('x', l); d.dispatchEvent(new Event('x')); r.toString()", "1")] + [TestCase("var s, d = document.createElement('div'), l = { handleEvent: function () { s = this; } }; d.addEventListener('x', l); d.dispatchEvent(new Event('x')); (s === l).toString()", "true")] + [TestCase("var r = 0, d = document.createElement('div'), l = { handleEvent: function () { r++; } }; d.addEventListener('x', l); d.removeEventListener('x', l); d.dispatchEvent(new Event('x')); r.toString()", "0")] + [TestCase("var r = 0, d = document.createElement('div'), l = { handleEvent: function () { r++; } }; d.addEventListener('x', l, { once: true }); d.dispatchEvent(new Event('x')); d.dispatchEvent(new Event('x')); r.toString()", "1")] + [TestCase("var r = 0, d = document.createElement('div'); d.handleEvent = function (e) { r += e.type === 'x' ? 1 : 0; }; d.addEventListener('x', d, { capture: true }); d.dispatchEvent(new Event('x')); r.toString()", "1")] + [TestCase("var r = 0, d = document.createElement('div'), l = { handleEvent: function () { r = 1; } }; d.addEventListener('x', l); l.handleEvent = function () { r = 2; }; d.dispatchEvent(new Event('x')); r.toString()", "2")] + public async Task ObjectsCanListen(String script, String expected) + { + Assert.AreEqual(expected, await Eval(script)); + } + + private static async Task EvalInLoop(String script) + { + var cfg = Configuration.Default.WithJs().WithEventLoop().With(new ProbeService()); + var document = await BrowsingContext.New(cfg).OpenAsync(m => m.Content($"start")); + + for (var i = 0; i < 200 && document.Title == "start"; i++) + { + await Task.Delay(10); + } + + return document.Title; + } + + [TestCase("document.body.probeWait(function (v) { return v + '!'; }).then(function (r) { document.title = r; })", "given!")] + [TestCase("document.body.probeWait(async function (v) { await null; return v + '?'; }).then(function (r) { document.title = r; })", "given?")] + [TestCase("document.body.probeWait(function (v) { return Promise.reject('no'); }).catch(function (r) { document.title = r; })", "no")] + [TestCase("document.body.probeWait(function (v) { throw 'thrown'; }).catch(function (r) { document.title = r; })", "thrown")] + [TestCase("document.body.probeWait(function (v) { return { then: function (a) { a('thenable'); } }; }).then(function (r) { document.title = r; })", "thenable")] + [TestCase("document.body.probeWaitPlain(function (v) { document.title = v; })", "given")] + [TestCase("document.body.probeWaitPlain(async function (v) { await null; throw 'late'; }).catch(function (r) { document.title = r; })", "late")] + public async Task CallbacksAreWaitedFor(String script, String expected) + { + Assert.AreEqual(expected, await EvalInLoop(script)); + } + + [TestCase("var o = { a: 1 }, c = document.body.probeClone(o); c.a = 2; o.a + ',' + c.a", "1,2")] + [TestCase("JSON.stringify(document.body.probeClone({ a: { b: [1, 'x', null, true] }, c: 'd' }))", "{\"a\":{\"b\":[1,\"x\",null,true]},\"c\":\"d\"}")] + [TestCase("var d = document.body.probeClone(new Date(5)); (d instanceof Date) + ',' + d.getTime()", "true,5")] + [TestCase("var d = document.body.probeClone({ d: new Date(7) }).d; (d instanceof Date) + ',' + d.getTime()", "true,7")] + [TestCase("var b = document.body.probeClone(new Uint8Array([1, 2])); (b instanceof ArrayBuffer) + ',' + b.byteLength", "true,2")] + [TestCase("var b = document.body.probeClone({ b: new Uint8Array([1, 2, 3]) }).b; (b instanceof ArrayBuffer) + ',' + b.byteLength", "true,3")] + [TestCase("try { document.body.probeClone(function () {}); } catch (e) { e.name; }", "DataCloneError")] + [TestCase("try { document.body.probeClone({ a: document.body }); } catch (e) { e.name; }", "DataCloneError")] + [TestCase("String(document.body.probeUndefined() === undefined)", "true")] + public async Task ValuesAreClonedForTheDom(String script, String expected) + { + Assert.AreEqual(expected, await Eval(script)); + } + + [TestCase("document.body.innerHTML = ''; var w = document.createTreeWalker(document.body, 1, { acceptNode: function (n) { return n.tagName === 'B' ? 1 : 3; } }); w.nextNode().tagName + ',' + w.nextNode()", "B,null")] + [TestCase("document.body.innerHTML = ''; var w = document.createTreeWalker(document.body, 1, function (n) { return n.tagName === 'A' ? NodeFilter.FILTER_SKIP : NodeFilter.FILTER_ACCEPT; }); w.nextNode().tagName", "B")] + [TestCase("document.body.innerHTML = ''; var o = { skip: 'A', acceptNode: function (n) { return n.tagName === this.skip ? 2 : 1; } }; var i = document.createNodeIterator(document.body, 1, o); i.nextNode().tagName + ',' + i.nextNode().tagName", "BODY,B")] + [TestCase("document.body.innerHTML = ''; var w = document.createTreeWalker(document.body, 1, null); w.nextNode().tagName", "A")] + [TestCase("document.body.innerHTML = ''; try { document.createTreeWalker(document.body, 1, {}).nextNode(); } catch (e) { e.name; }", "TypeError")] + public async Task ObjectsCanFilterNodes(String script, String expected) + { + Assert.AreEqual(expected, await Eval(script)); + } + + [TestCase("JSON.stringify(new CustomEvent('x', { detail: { a: 1 } }).detail)", "{\"a\":1}")] + [TestCase("String(new CustomEvent('x', { detail: 5 }).detail)", "5")] + [TestCase("String(new CustomEvent('x').detail)", "null")] + [TestCase("var d = {}; String(new CustomEvent('x', { detail: d }).detail === d)", "true")] + [TestCase("class E extends CustomEvent { constructor(d) { super('x', { detail: d }); } } JSON.stringify(new E({ a: 2 }).detail)", "{\"a\":2}")] + [TestCase("var r; document.body.addEventListener('y', function (e) { r = e.detail; }); document.body.dispatchEvent(new CustomEvent('y', { detail: 'data' })); r", "data")] + [TestCase("var e = new CustomEvent('x', { detail: 1, bubbles: true, cancelable: true }); e.bubbles + ',' + e.cancelable", "true,true")] + [TestCase("var t = document.body; String(new FocusEvent('focus', { relatedTarget: t }).relatedTarget === t)", "true")] + public async Task EventDetailsGetThroughTheirConstructors(String script, String expected) + { + Assert.AreEqual(expected, await Eval(script)); + } + + [TestCase("document.body.innerHTML = ''; var n = 0; for (var e of document.body.children) { n++; } n.toString()", "2")] + [TestCase("document.body.innerHTML = ''; [...document.links].length.toString()", "2")] + [TestCase("document.body.innerHTML = ''; var n = 0; for (var e of document.images) { n++; } n.toString()", "1")] + [TestCase("document.body.innerHTML = '
'; Array.from(document.forms).length.toString()", "1")] + [TestCase("String(document.body.children === document.body.children)", "true")] + [TestCase("document.body.innerHTML = ''; var c = document.body.children; document.body.appendChild(document.createElement('b')); c.length + c[1].tagName", "2B")] + [TestCase("document.body.className = 'x y'; var l = document.body.classList; l.length + ',' + l[1] + ',' + l.contains('y')", "2,y,true")] + public async Task CollectionsOfAttributesAreIterable(String script, String expected) + { + Assert.AreEqual(expected, await Eval(script)); + } + + [TestCase("var o = { a: [1, { b: 2 }] }; var c = structuredClone(o); c.a[1].b = 3; o.a[1].b + ',' + c.a[1].b", "2,3")] + [TestCase("structuredClone(5) + ',' + structuredClone('x') + ',' + structuredClone(null)", "5,x,null")] + [TestCase("var d = structuredClone(new Date(9)); (d instanceof Date) + ',' + d.getTime()", "true,9")] + [TestCase("try { structuredClone(function () {}); } catch (e) { e.name; }", "DataCloneError")] + [TestCase("try { structuredClone(document.body); } catch (e) { e.name; }", "DataCloneError")] + [TestCase("String(typeof window.isSecureContext)", "boolean")] + [TestCase("document.body.innerHTML = ''; String(window.length)", "1")] + [TestCase("document.body.className = 'a b'; var r = []; document.body.classList.forEach(function (t) { r.push(t); }); r.join()", "a,b")] + [TestCase("document.body.className = 'a b'; Array.from(document.body.classList.values()).join()", "a,b")] + [TestCase("document.body.className = 'a b'; var r = []; for (var t of document.body.classList) { r.push(t); } r.join()", "a,b")] + [TestCase("document.body.className = 'a b'; document.body.classList.replace('a', 'c') + ',' + document.body.className", "true,c b")] + [TestCase("document.body.className = 'a'; document.body.classList.value = 'x y'; document.body.className + ',' + document.body.classList.value", "x y,x y")] + [TestCase("document.body.className = 'a'; document.body.classList.add('b'); document.body.classList.contains('b') + ',' + document.body.classList.length", "true,2")] + [TestCase("var e = new KeyboardEvent('keydown', { key: 'a', code: 'KeyA', ctrlKey: true, shiftKey: true, repeat: true, bubbles: true }); [e.key, e.code, e.ctrlKey, e.shiftKey, e.altKey, e.repeat, e.bubbles].join()", "a,KeyA,true,true,false,true,true")] + [TestCase("var e = new KeyboardEvent('keydown'); [e.type, e.key, e.code, e.ctrlKey].join()", "keydown,,,false")] + [TestCase("var e = new KeyboardEvent('keydown', {}); [e.key, e.code, e.ctrlKey].join()", ",,false")] + [TestCase("var t = document.body; var e = new MouseEvent('click', { clientX: 3, button: 2, buttons: 2, ctrlKey: true, relatedTarget: t }); [e.clientX, e.x, e.button, e.buttons, e.ctrlKey, e.relatedTarget === t].join()", "3,3,2,2,true,true")] + [TestCase("var e = new MouseEvent('click', {}); [e.clientX, e.button, e.buttons].join()", "0,0,0")] + [TestCase("var e = new MouseEvent('click'); [e.type, e.buttons].join()", "click,0")] + [TestCase("var t = document.body; var e = new WheelEvent('wheel', { deltaY: 5, ctrlKey: true, relatedTarget: t }); [e.deltaY, e.deltaX, e.ctrlKey, e.relatedTarget === t].join()", "5,0,true,true")] + [TestCase("var e = new PointerEvent('pointerdown', { pointerId: 7, pointerType: 'pen', clientX: 2, pressure: 0.5, isPrimary: true }); [e.pointerId, e.pointerType, e.clientX, e.pressure, e.isPrimary, e instanceof MouseEvent].join()", "7,pen,2,0.5,true,true")] + [TestCase("var e = new Event('x', { cancelable: true }); e.returnValue = false; e.defaultPrevented + ',' + e.returnValue", "true,false")] + [TestCase("JSON.stringify(new DOMPoint(1, 2))", "{\"x\":1,\"y\":2,\"z\":0,\"w\":1}")] + [TestCase("document.body.innerHTML = '
'; document.body.firstChild.getAttributeNames().join()", "a,b")] + [TestCase("document.body.innerHTML = '
'; var d = document.body.firstChild; d.insertAdjacentElement('beforeend', document.createElement('i')); d.insertAdjacentText('beforeend', 'x'); d.innerHTML", "x")] + [TestCase("document.scrollingElement === document.documentElement ? document.visibilityState + ',' + document.hidden : 'no'", "visible,false")] + [TestCase("String(document.body.isSameNode(document.body))", "true")] + public async Task MembersOfTheDomWork(String script, String expected) + { + Assert.AreEqual(expected, await Eval(script)); + } + + [TestCase("try { document.body.probeDenied(); } catch (e) { e.name + ',' + (e instanceof DOMException) + ',' + e.code; }", "NotAllowedError,true,0")] + [TestCase("try { document.body.probeFailed(); } catch (e) { e.name + ',' + e.message; }", "Error,something went wrong")] + public async Task ExceptionsWithoutACodeKeepTheirName(String script, String expected) + { + Assert.AreEqual(expected, await Eval(script)); + } + + [Test] + public async Task EntriesAreAskedForByName() + { + var script = @"var p = new ProbeIterable(); p.entries = function () { return [['x', 'y']]; }; [...p][0].join()"; + Assert.AreEqual("x,y", await Eval(script)); + } + } +} diff --git a/src/AngleSharp.Js.Tests/JavascriptErrorTests.cs b/src/AngleSharp.Js.Tests/JavascriptErrorTests.cs index 48d4b168..0d4a77ca 100644 --- a/src/AngleSharp.Js.Tests/JavascriptErrorTests.cs +++ b/src/AngleSharp.Js.Tests/JavascriptErrorTests.cs @@ -184,6 +184,22 @@ public async Task OnErrorOfAnElementGetsTheEvent() Assert.AreEqual("object1", document.Title); } + [Test] + public async Task SyntaxErrorOfAScriptReachesTheWindowWithItsPosition() + { + var context = BrowsingContext.New(Configuration.Default.WithJs().WithEventLoop()); + var errors = context.CollectErrors(); + var document = await context.OpenAsync(r => r.Address("https://example.com/page").Content(@" +")); + + Assert.AreEqual("https://example.com/page,6,true", document.Title); + Assert.AreEqual(1, errors.Count); + } + [Test] public async Task InlineScriptReportsItsPositionInTheDocument() { diff --git a/src/AngleSharp.Js.Tests/MicrotaskCheckpointTests.cs b/src/AngleSharp.Js.Tests/MicrotaskCheckpointTests.cs new file mode 100644 index 00000000..8c9a03f4 --- /dev/null +++ b/src/AngleSharp.Js.Tests/MicrotaskCheckpointTests.cs @@ -0,0 +1,96 @@ +namespace AngleSharp.Js.Tests +{ + using AngleSharp.Browser; + using AngleSharp.Dom; + using NUnit.Framework; + using System; + using System.Threading.Tasks; + + /// + /// The promise jobs of a callback of the host run when no script is running anymore (HTML 8.1.4.4). + /// + [TestFixture] + public class MicrotaskCheckpointTests + { + private static async Task Load(String script) + { + var cfg = Configuration.Default.WithJs().WithEventLoop(); + return await BrowsingContext.New(cfg).OpenAsync(m => m.Content($"start
")); + } + + private static async Task Run(String script, String expected) + { + var document = await Load(script); + + for (var i = 0; i < 300 && document.Title != expected; i++) + { + await Task.Delay(10); + } + + return document.Title; + } + + [TestCase("new Promise(function (s) { setTimeout(s, 5); }).then(function () { document.title = 'ok'; })")] + [TestCase("new Promise(function (s) { setTimeout(function () { s(); }, 5); }).then(function () { document.title = 'ok'; })")] + [TestCase("(async function () { await new Promise(function (s) { setTimeout(s, 5); }); document.title = 'ok'; })()")] + [TestCase("(async function () { await null; await new Promise(function (s) { setTimeout(s, 5); }); await new Promise(function (s) { setTimeout(s, 5); }); document.title = 'ok'; })()")] + [TestCase("setTimeout(async function () { await new Promise(function (s) { setTimeout(s, 5); }); document.title = 'ok'; }, 1)")] + [TestCase("var i = setInterval(function () { clearInterval(i); Promise.resolve().then(function () { document.title = 'ok'; }); }, 5)")] + [TestCase("requestAnimationFrame && new Promise(function (s) { setTimeout(s, 1); }).then(function () { return new Promise(function (s) { setTimeout(s, 1); }); }).then(function () { document.title = 'ok'; })")] + [TestCase("new MutationObserver(function () { Promise.resolve().then(function () { document.title = 'ok'; }); }).observe(document.body, { attributes: true }); setTimeout(function () { document.body.setAttribute('a', '1'); }, 1)")] + public async Task JobsOfCallbacksRun(String script) + { + Assert.AreEqual("ok", await Run(script, "ok")); + } + + [Test] + public async Task JobsRunBetweenTimers() + { + var script = @"var log = []; + setTimeout(function () { log.push('a'); Promise.resolve().then(function () { log.push('b'); }); }, 1); + setTimeout(function () { log.push('c'); document.title = log.join(''); }, 30);"; + + Assert.AreEqual("abc", await Run(script, "abc")); + } + + [Test] + public async Task DispatchByScriptKeepsJobsWaiting() + { + var script = @"var log = [], d = document.getElementById('d'); + d.addEventListener('x', function () { Promise.resolve().then(function () { log.push('job'); }); }); + d.dispatchEvent(new Event('x')); + log.push('after'); + document.title = log.join(); + setTimeout(function () { document.title = log.join(); }, 5);"; + + Assert.AreEqual("after,job", await Run(script, "after,job")); + } + + [Test] + public async Task JobsRunBetweenListenersOfADispatchByTheHost() + { + var document = await Load(@"var log = window.log = [], d = document.getElementById('d'); + d.addEventListener('x', function () { log.push('1'); Promise.resolve().then(function () { log.push('job'); }); }); + d.addEventListener('x', function () { log.push('2'); document.title = log.join(); });"); + + await document.Context.GetService().EnqueueAsync(_ => + { + document.GetElementById("d").Dispatch(new AngleSharp.Dom.Events.Event("x")); + return true; + }, TaskPriority.Normal); + + Assert.AreEqual("1,job,2", document.Title); + } + + [Test] + public async Task AThrowingJobDoesNotStopTheOthers() + { + var script = @"setTimeout(function () { + queueMicrotask(function () { throw new Error('job'); }); + queueMicrotask(function () { document.title = 'ok'; }); + }, 1);"; + + Assert.AreEqual("ok", await Run(script, "ok")); + } + } +} diff --git a/src/AngleSharp.Js.Tests/ModuleTests.cs b/src/AngleSharp.Js.Tests/ModuleTests.cs new file mode 100644 index 00000000..d42f8a7c --- /dev/null +++ b/src/AngleSharp.Js.Tests/ModuleTests.cs @@ -0,0 +1,207 @@ +namespace AngleSharp.Js.Tests +{ + using AngleSharp.Dom; + using AngleSharp.Io; + using AngleSharp.Js.Tests.Mocks; + using Jint.Runtime; + using NUnit.Framework; + using System; + using System.Collections.Generic; + using System.Threading.Tasks; + + /// + /// What a module knows about where it comes from, and what it finds from there. + /// + [TestFixture] + public class ModuleTests + { + private const String Page = "https://example.com/dir/page"; + + private static IBrowsingContext CreateContext(Dictionary modules) + { + var config = Configuration.Default + .WithJs() + .WithEventLoop() + .With(new MockHttpClientRequester(modules)) + .WithDefaultLoader(new LoaderOptions { IsResourceLoadingEnabled = true }); + return BrowsingContext.New(config); + } + + private static async Task OpenAsync(String html, Dictionary modules = null) + { + var document = await CreateContext(modules ?? new Dictionary()).OpenAsync(r => r.Address(Page).Content(html)); + await document.WhenStable(); + return document; + } + + [Test] + public async Task ExternalModuleKnowsItsResolvedAddress() + { + var document = await OpenAsync("", new Dictionary + { + { "/js/app.js", "document.title = import.meta.url;" }, + }); + + Assert.AreEqual("https://example.com/js/app.js", document.Title); + } + + [Test] + public async Task InlineModuleKnowsTheAddressOfItsDocument() + { + var document = await OpenAsync(""); + + Assert.AreEqual(Page, document.Title); + } + + [Test] + public async Task InlineModuleKnowsTheBaseOfItsDocument() + { + var document = await OpenAsync(""); + + Assert.AreEqual("https://other.example/base/", document.Title); + } + + [Test] + public async Task ImportedModuleKnowsItsAddress() + { + var document = await OpenAsync("", new Dictionary + { + { "/js/app.js", "import './dep.js';" }, + { "/js/dep.js", "document.title = import.meta.url;" }, + }); + + Assert.AreEqual("https://example.com/js/dep.js", document.Title); + } + + [Test] + public async Task ModuleFromAnImportMapKnowsItsAddress() + { + var document = await OpenAsync(@"", new Dictionary + { + { "/js/dep.js", "document.title = import.meta.url;" }, + }); + + Assert.AreEqual("https://example.com/js/dep.js", document.Title); + } + + [TestCase("./sibling.js", "/js/sub/sibling.js")] + [TestCase("../parent.js", "/js/parent.js")] + [TestCase("/root.js", "/root.js")] + [TestCase("https://example.com/absolute.js", "/absolute.js")] + public async Task ImportsAreFoundFromTheirModule(String specifier, String path) + { + var document = await OpenAsync("", new Dictionary + { + { "/js/sub/app.js", $"import '{specifier}';" }, + { path, "document.title = 'found';" }, + }); + + Assert.AreEqual("found", document.Title); + } + + [TestCase("./dep.js", "/dir/dep.js")] + [TestCase("/dep.js", "/dep.js")] + public async Task ImportsOfAnInlineModuleAreFoundFromItsDocument(String specifier, String path) + { + var document = await OpenAsync($"", new Dictionary + { + { path, "document.title = 'found';" }, + }); + + Assert.AreEqual("found", document.Title); + } + + [Test] + public async Task ImportsOfAnImportedModuleAreFoundFromThatModule() + { + var document = await OpenAsync("", new Dictionary + { + { "/a/one.js", "import './b/two.js';" }, + { "/a/b/two.js", "import '../c/three.js';" }, + { "/a/c/three.js", "document.title = import.meta.url;" }, + }); + + Assert.AreEqual("https://example.com/a/c/three.js", document.Title); + } + + [Test] + public async Task SyntaxErrorOfAnImportedModuleReachesTheWindow() + { + var document = await OpenAsync("", new Dictionary + { + { "/a.js", "import './broken.js';" }, + { "/broken.js", "function (" }, + }); + + Assert.AreEqual("caught", document.Title); + } + + [Test] + public async Task ImportedJsonModuleIsStillFound() + { + var document = await OpenAsync("", new Dictionary + { + { "/dir/data.json", "{ \"value\": \"from json\" }" }, + }); + + Assert.AreEqual("from json", document.Title); + } + + [Test] + public async Task ModuleRunsOnceHoweverItIsReached() + { + var document = await OpenAsync("0", new Dictionary + { + { "/a.js", "document.title = String(Number(document.title) + 1);" }, + { "/b.js", "import './a.js';" }, + }); + + Assert.AreEqual("1", document.Title); + } + + [Test] + public async Task SeveralInlineModulesAreDifferentModules() + { + var document = await OpenAsync("0"); + + Assert.AreEqual("2", document.Title); + } + + [Test] + public async Task ErrorInAnExternalModuleTellsItsAddress() + { + var context = CreateContext(new Dictionary + { + { "/js/app.js", "throw new Error('from the module');" }, + }); + var errors = context.CollectErrors(); + var document = await context.OpenAsync(r => r.Address(Page).Content("")); + await document.WhenStable(); + + Assert.AreEqual(1, errors.Count); + Assert.AreEqual("https://example.com/js/app.js", ((JavaScriptException)errors[0]).Location.SourceFile); + } + + [Test] + public async Task ErrorInAnInlineModuleTellsItsPlaceInTheDocument() + { + var context = CreateContext(new Dictionary()); + var errors = context.CollectErrors(); + var document = await context.OpenAsync(r => r.Address(Page).Content("\n\n")); + await document.WhenStable(); + + Assert.AreEqual(1, errors.Count); + var location = ((JavaScriptException)errors[0]).Location; + StringAssert.StartsWith(Page, location.SourceFile); + Assert.AreEqual(4, location.Start.Line); + } + + [Test] + public async Task SyntaxErrorOfAModuleReachesTheWindow() + { + var document = await OpenAsync(""); + + Assert.AreEqual("caught", document.Title); + } + } +} diff --git a/src/AngleSharp.Js.Tests/PerformanceTests.cs b/src/AngleSharp.Js.Tests/PerformanceTests.cs index f6c6a59d..388f6118 100644 --- a/src/AngleSharp.Js.Tests/PerformanceTests.cs +++ b/src/AngleSharp.Js.Tests/PerformanceTests.cs @@ -164,6 +164,87 @@ public async Task EntriesAreCleared() Assert.AreEqual("b+d,0", result); } + private static async Task Observed(String script) + { + var config = Configuration.Default.WithJs().WithEventLoop(); + var document = await BrowsingContext.New(config).OpenAsync(m => m.Content($"")); + + for (var i = 0; i < 200 && document.Title.Length == 0; i++) + { + await Task.Delay(10); + } + + return document.Title; + } + + [Test] + public async Task ObserverGetsNewEntriesOfItsTypesLater() + { + var result = await Observed(@" + var sync = true; + new PerformanceObserver(function (list, observer) { + document.title = [sync, list.getEntries().map(function (e) { return e.name; }).join('+'), list.getEntriesByType('measure').length, list.getEntriesByName('a').length].join(); + }).observe({ entryTypes: ['mark'] }); + performance.mark('a'); + performance.measure('ignored'); + performance.mark('b'); + sync = false;"); + + Assert.AreEqual("false,a+b,0,1", result); + } + + [Test] + public async Task ObserverWithBufferedGetsWhatIsThereAlready() + { + var result = await Observed(@" + performance.mark('before'); + new PerformanceObserver(function (list) { + document.title = list.getEntries().map(function (e) { return e.name; }).join('+'); + }).observe({ type: 'mark', buffered: true });"); + + Assert.AreEqual("before", result); + } + + [Test] + public async Task ObserverOfAnUnknownTypeIsFine() + { + var result = await Observed(@" + var observer = new PerformanceObserver(function () { document.title = 'wrong'; }); + observer.observe({ type: 'longtask' }); + observer.observe({ entryTypes: ['paint', 'navigation'] }); + performance.mark('a'); + setTimeout(function () { document.title = 'fine'; }, 20);"); + + Assert.AreEqual("fine", result); + } + + [Test] + public async Task DisconnectedObserverGetsNothing() + { + var result = await Observed(@" + var observer = new PerformanceObserver(function () { document.title = 'wrong'; }); + observer.observe({ entryTypes: ['mark'] }); + performance.mark('a'); + observer.disconnect(); + performance.mark('b'); + setTimeout(function () { document.title = 'fine'; }, 20);"); + + Assert.AreEqual("fine", result); + } + + [Test] + public async Task TakeRecordsTakesWhatTheCallbackWouldGet() + { + var result = await Observed(@" + var observer = new PerformanceObserver(function () { document.title = 'wrong'; }); + observer.observe({ entryTypes: ['mark'] }); + performance.mark('a'); + var taken = observer.takeRecords().length; + setTimeout(function () { document.title = String(taken); }, 20);"); + + Assert.AreEqual("1", result); + } + [Test] public async Task EntryConvertsToJson() { diff --git a/src/AngleSharp.Js.Tests/ScriptEvalTests.cs b/src/AngleSharp.Js.Tests/ScriptEvalTests.cs index 23258ad2..e8d645b8 100644 --- a/src/AngleSharp.Js.Tests/ScriptEvalTests.cs +++ b/src/AngleSharp.Js.Tests/ScriptEvalTests.cs @@ -80,7 +80,7 @@ public async Task CreateCustomEventViaGeneralConstructorShouldWork() [Test] public async Task CreateCustomEventViaCustomConstructorWithDetailShouldWork() { - var result = await EvaluateComplexScriptAsync("var ev = new CustomEvent('bar', { bubbles: false, cancelable: false, details: 'baz' });", SetResult("ev.type + ev.detail")); + var result = await EvaluateComplexScriptAsync("var ev = new CustomEvent('bar', { bubbles: false, cancelable: false, detail: 'baz' });", SetResult("ev.type + ev.detail")); Assert.AreEqual("barbaz", result); } diff --git a/src/AngleSharp.Js.Tests/TextEncodingTests.cs b/src/AngleSharp.Js.Tests/TextEncodingTests.cs new file mode 100644 index 00000000..0789ca98 --- /dev/null +++ b/src/AngleSharp.Js.Tests/TextEncodingTests.cs @@ -0,0 +1,135 @@ +namespace AngleSharp.Js.Tests +{ + using NUnit.Framework; + using System; + using System.Threading.Tasks; + + /// + /// The TextEncoder and TextDecoder of the Encoding standard. + /// + [TestFixture] + public class TextEncodingTests + { + private static Task Eval(String body) => $"(function () {{ {body} }})()".EvalScriptAsync(); + + [Test] + public async Task EncoderIsAvailableOnTheWindowAndGlobalThis() + { + Assert.AreEqual("function,function,function", await Eval("return [typeof TextEncoder, typeof window.TextEncoder, typeof globalThis.TextEncoder].join();")); + } + + [Test] + public async Task EncoderTellsItsEncoding() + { + Assert.AreEqual("utf-8", await Eval("return new TextEncoder().encoding;")); + } + + [TestCase("'abc'", "97,98,99")] + [TestCase("'ä€😀'", "195,164,226,130,172,240,159,152,128")] + [TestCase("''", "")] + [TestCase("undefined", "")] + [TestCase("'\\uD800'", "239,191,189")] + public async Task EncoderEncodes(String input, String expected) + { + Assert.AreEqual(expected, await Eval($"return Array.from(new TextEncoder().encode({input})).join();")); + } + + [Test] + public async Task EncoderGivesAUint8Array() + { + Assert.AreEqual("true,3", await Eval("var r = new TextEncoder().encode('abc'); return [r instanceof Uint8Array, r.length].join();")); + } + + [Test] + public async Task EncodeIntoWritesWhatFits() + { + var result = await Eval(@" + var target = new Uint8Array(5); + var r = new TextEncoder().encodeInto('a€😀', target); + return [r.read, r.written, Array.from(target).join('+')].join();"); + + Assert.AreEqual("2,4,97+226+130+172+0", result); + } + + [Test] + public async Task EncodeIntoReadsWholeSurrogatePairs() + { + var result = await Eval(@" + var target = new Uint8Array(4); + var r = new TextEncoder().encodeInto('😀', target); + return [r.read, r.written].join();"); + + Assert.AreEqual("2,4", result); + } + + [TestCase("new Uint8Array([104, 105])", "hi")] + [TestCase("new Uint8Array([104, 105]).buffer", "hi")] + [TestCase("new DataView(new Uint8Array([104, 105]).buffer)", "hi")] + [TestCase("undefined", "")] + public async Task DecoderDecodes(String input, String expected) + { + Assert.AreEqual(expected, await Eval($"return new TextDecoder().decode({input});")); + } + + [Test] + public async Task RoundTripKeepsText() + { + Assert.AreEqual("ä€😀", await Eval("return new TextDecoder().decode(new TextEncoder().encode('ä€😀'));")); + } + + [Test] + public async Task DecoderTellsWhatItWasCreatedWith() + { + Assert.AreEqual("utf-8,false,false,true,true", await Eval(@" + var a = new TextDecoder(); + var b = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }); + return [a.encoding, a.fatal, a.ignoreBOM, b.fatal, b.ignoreBOM].join();")); + } + + [Test] + public async Task DecoderKnowsOtherEncodings() + { + Assert.AreEqual("ä", await Eval("return new TextDecoder('iso-8859-1').decode(new Uint8Array([228]));")); + } + + [Test] + public async Task DecoderRefusesUnknownEncodings() + { + Assert.AreEqual("RangeError", await Eval("try { new TextDecoder('no-such-encoding'); return 'no error'; } catch (e) { return e.name; }")); + } + + [Test] + public async Task DecoderReplacesInvalidInput() + { + Assert.AreEqual("a�b", await Eval("return new TextDecoder().decode(new Uint8Array([97, 255, 98]));")); + } + + [Test] + public async Task FatalDecoderThrowsOnInvalidInput() + { + Assert.AreEqual("TypeError", await Eval("try { new TextDecoder('utf-8', { fatal: true }).decode(new Uint8Array([255])); return 'no error'; } catch (e) { return e.name; }")); + } + + [Test] + public async Task DecoderDropsAByteOrderMark() + { + Assert.AreEqual("hi", await Eval("return new TextDecoder().decode(new Uint8Array([239, 187, 191, 104, 105]));")); + } + + [Test] + public async Task DecoderKeepsAByteOrderMarkWhenAskedTo() + { + Assert.AreEqual("3", await Eval("return String(new TextDecoder('utf-8', { ignoreBOM: true }).decode(new Uint8Array([239, 187, 191, 104, 105])).length);")); + } + + [Test] + public async Task StreamingDecoderWaitsForTheRestOfACharacter() + { + Assert.AreEqual("|€", await Eval(@" + var d = new TextDecoder(); + var first = d.decode(new Uint8Array([226, 130]), { stream: true }); + var second = d.decode(new Uint8Array([172])); + return first + '|' + second;")); + } + } +} diff --git a/src/AngleSharp.Js.Tests/WebIdlMemberTests.cs b/src/AngleSharp.Js.Tests/WebIdlMemberTests.cs new file mode 100644 index 00000000..8a5f885c --- /dev/null +++ b/src/AngleSharp.Js.Tests/WebIdlMemberTests.cs @@ -0,0 +1,139 @@ +namespace AngleSharp.Js.Tests +{ + using AngleSharp.Dom; + using NUnit.Framework; + using System; + using System.Threading.Tasks; + + /// + /// The members of the DOM are what WebIDL says they are, as pages rely on patching them. + /// + [TestFixture] + public class WebIdlMemberTests + { + private static Task Eval(String body) => $"(function () {{ {body} }})()".EvalScriptAsync(); + + private static async Task EvalInPage(String script) + { + var config = Configuration.Default.WithJs().WithEventLoop(); + var document = await BrowsingContext.New(config).OpenAsync(m => m.Content($"
")); + return document.Title; + } + + [TestCase("Document.prototype", "querySelector")] + [TestCase("Element.prototype", "querySelector")] + [TestCase("Element.prototype", "setAttribute")] + [TestCase("Node.prototype", "appendChild")] + [TestCase("EventTarget.prototype", "addEventListener")] + [TestCase("HTMLElement.prototype", "click")] + public async Task OperationsAreWritableAndConfigurable(String prototype, String name) + { + var result = await EvalInPage($"var d = Object.getOwnPropertyDescriptor({prototype}, '{name}'); return d ? [typeof d.value, d.writable, d.configurable].join() : 'none';"); + + Assert.AreEqual("function,true,true", result); + } + + [TestCase("Node.prototype", "firstChild")] + [TestCase("Element.prototype", "id")] + public async Task AttributesAreConfigurableAccessors(String prototype, String name) + { + var result = await EvalInPage($"var d = Object.getOwnPropertyDescriptor({prototype}, '{name}'); return d ? [typeof d.get, d.configurable].join() : 'none';"); + + Assert.AreEqual("function,true", result); + } + + [Test] + public async Task PatchingAMethodOfElementReachesEveryElement() + { + Assert.AreEqual("patched-s,patched-s,s", await EvalInPage(@" + var original = Element.prototype.querySelector; + Object.defineProperty(Element.prototype, 'querySelector', { value: function (selector) { return { id: 'patched-' + original.call(this, selector).id }; } }); + var viaDiv = document.getElementById('d').querySelector('span').id; + var viaBody = document.body.querySelector('span').id; + Object.defineProperty(Element.prototype, 'querySelector', { value: original }); + return [viaDiv, viaBody, document.body.querySelector('span').id].join();")); + } + + [Test] + public async Task AssigningAMethodOfEventTargetReachesEveryTarget() + { + Assert.AreEqual("1,1,1", await EvalInPage(@" + var calls = { el: 0, doc: 0, win: 0 }, original = EventTarget.prototype.addEventListener; + EventTarget.prototype.addEventListener = function (type, listener) { + if (this === window) { calls.win++; } else if (this === document) { calls.doc++; } else { calls.el++; } + return original.apply(this, arguments); + }; + document.getElementById('d').addEventListener('x', function () {}); + document.addEventListener('x', function () {}); + window.addEventListener('x', function () {}); + EventTarget.prototype.addEventListener = original; + return [calls.el, calls.doc, calls.win].join();")); + } + + [Test] + public async Task OverridingAnAccessorOnAPrototypeWorks() + { + Assert.AreEqual("wrapped-d,d", await EvalInPage(@" + var original = Object.getOwnPropertyDescriptor(Element.prototype, 'id'); + Object.defineProperty(Element.prototype, 'id', { get: function () { return 'wrapped-' + original.get.call(this); }, set: original.set, configurable: true }); + var patched = document.getElementById('d').id; + Object.defineProperty(Element.prototype, 'id', original); + return [patched, document.getElementById('d').id].join();")); + } + + [Test] + public async Task ALibraryMethodOnAPrototypeIsFoundByItsElements() + { + Assert.AreEqual("mine,mine", await EvalInPage(@" + Element.prototype.mine = function () { return 'mine'; }; + return [document.body.mine(), document.getElementById('d').mine()].join();")); + } + + [Test] + public async Task ASpecificElementTypeMayOverrideOnItsOwnPrototype() + { + Assert.AreEqual("div,x", await EvalInPage(@" + HTMLDivElement.prototype.getAttribute = function () { return 'div'; }; + var div = document.getElementById('d').getAttribute('id'); + document.body.setAttribute('a', 'x'); + return [div, document.body.getAttribute('a')].join();")); + } + + [Test] + public async Task PrototypeChainIsTheOneOfTheStandard() + { + Assert.AreEqual("true,true,true,true", await EvalInPage(@" + var div = document.getElementById('d'); + return [ + Object.getPrototypeOf(div) === HTMLDivElement.prototype, + Object.getPrototypeOf(HTMLDivElement.prototype) === HTMLElement.prototype, + Object.getPrototypeOf(HTMLElement.prototype) === Element.prototype, + Object.getPrototypeOf(Node.prototype) === EventTarget.prototype + ].join();")); + } + + [Test] + public async Task InterfaceObjectsCanBeReplacedOnTheWindow() + { + Assert.AreEqual("replaced,replaced", await EvalInPage(@" + var original = window.HTMLElement; + window.HTMLElement = 'replaced'; + var results = [window.HTMLElement, HTMLElement]; + window.HTMLElement = original; + return results.join();")); + } + + [Test] + public async Task PopoverPolyfillPatternWorks() + { + Assert.AreEqual("s,s", await EvalInPage(@" + function M(e, t, n) { var o = e[t]; Object.defineProperty(e, t, { value: function (e) { return o.call(this, n(e)); } }); } + function fix(s) { return s && s.replace(':popover-open', '.popover-open'); } + M(Document.prototype, 'querySelector', fix); + M(Document.prototype, 'querySelectorAll', fix); + M(Element.prototype, 'querySelector', fix); + M(Element.prototype, 'querySelectorAll', fix); + return [document.querySelector('span').id, document.getElementById('d').querySelectorAll('span')[0].id].join();")); + } + } +} diff --git a/src/AngleSharp.Js.Tests/WorkerTests.cs b/src/AngleSharp.Js.Tests/WorkerTests.cs index 98cfdfea..858dda29 100644 --- a/src/AngleSharp.Js.Tests/WorkerTests.cs +++ b/src/AngleSharp.Js.Tests/WorkerTests.cs @@ -11,6 +11,115 @@ namespace AngleSharp.Js.Tests [TestFixture] public class WorkerTests { + private sealed class SourcesRequester : BaseRequester + { + private readonly System.Collections.Generic.Dictionary _sources; + + public SourcesRequester(System.Collections.Generic.Dictionary sources) + { + _sources = sources; + } + + public override Boolean SupportsProtocol(String protocol) => true; + + protected override Task PerformRequestAsync(AngleSharp.Io.Request request, System.Threading.CancellationToken cancel) + { + var found = _sources.TryGetValue(request.Address.Href, out var source); + return Task.FromResult(new AngleSharp.Io.DefaultResponse + { + Address = request.Address, + StatusCode = found ? System.Net.HttpStatusCode.OK : System.Net.HttpStatusCode.NotFound, + Content = new System.IO.MemoryStream(System.Text.Encoding.UTF8.GetBytes(source ?? String.Empty)), + }); + } + } + + private static async Task OpenWithSourcesAsync(params (String Address, String Source)[] sources) + { + var map = new System.Collections.Generic.Dictionary(); + + foreach (var (address, source) in sources) + { + map[address] = source; + } + + var config = Configuration.Default + .WithJs() + .WithEventLoop() + .With(new SourcesRequester(map)) + .WithDefaultLoader(new LoaderOptions { IsResourceLoadingEnabled = true }); + return await BrowsingContext.New(config).OpenAsync(m => m.Address("https://example.com/").Content("")); + } + + private static async Task WaitFor(Func condition) + { + for (var i = 0; i < 300 && !condition(); i++) + { + await Task.Delay(10); + } + + return condition(); + } + + [Test] + public async Task WorkerRunsAModuleWithItsImports() + { + var document = await OpenWithSourcesAsync( + ("https://example.com/worker.js", "import { v } from './dep.js'; self.__v = v + 1;"), + ("https://example.com/dep.js", "export const v = 41;")); + var worker = new AngleSharp.Js.Dom.Worker(document.DefaultView, "https://example.com/worker.js", new AngleSharp.Js.Dom.WorkerOptions { Type = "module" }); + + Assert.IsTrue(await WaitFor(() => worker.IsInitialized || worker.StartupError != null)); + Assert.IsNull(worker.StartupError, worker.StartupError?.ToString()); + Assert.AreEqual("42", worker.EvaluateInWorker("String(self.__v)")?.ToString()); + worker.Terminate(); + } + + [Test] + public async Task WorkerKnowsItsAddress() + { + var document = await OpenWithSourcesAsync(("https://example.com/a/worker.js", "")); + var worker = new AngleSharp.Js.Dom.Worker(document.DefaultView, "https://example.com/a/worker.js"); + + Assert.IsTrue(await WaitFor(() => worker.IsInitialized || worker.StartupError != null)); + Assert.AreEqual("https://example.com/a/worker.js", worker.EvaluateInWorker("location.href")?.ToString()); + worker.Terminate(); + } + + [Test] + public async Task ModuleSyntaxInAClassicWorkerGivesAnErrorEventAndPostingStaysQuiet() + { + var document = await OpenWithSourcesAsync(("https://example.com/worker.js", "import 'https://example.com/dep.js';")); + var worker = new AngleSharp.Js.Dom.Worker(document.DefaultView, "https://example.com/worker.js"); + var failed = false; + worker.Failed += (_, _) => failed = true; + + Assert.IsTrue(await WaitFor(() => failed)); + Assert.IsNotNull(worker.StartupError); + Assert.DoesNotThrow(() => worker.PostMessage("x")); + worker.Terminate(); + } + + [Test] + public async Task ScriptGetsAnErrorEventOfAWorkerAndCanPostToIt() + { + var document = await OpenWithSourcesAsync(("https://example.com/worker.js", "not valid javascript (")); + document.ExecuteScript(@" + var w = new Worker('https://example.com/worker.js', { type: 'classic' }); + w.onerror = function (e) { document.title = 'error:' + e.type; }; + w.postMessage('sent right away');"); + + Assert.IsTrue(await WaitFor(() => document.Title == "error:error"), document.Title); + } + + [Test] + public async Task UnknownWorkerTypeIsRefused() + { + var document = await OpenWithSourcesAsync(); + + Assert.Throws(() => new AngleSharp.Js.Dom.Worker(document.DefaultView, "https://example.com/w.js", new AngleSharp.Js.Dom.WorkerOptions { Type = "other" })); + } + [Test] public async Task WorkerConstructorShouldExistOnWindow() { diff --git a/src/AngleSharp.Js/Cache/DomShape.cs b/src/AngleSharp.Js/Cache/DomShape.cs index bb65f12a..5e510900 100644 --- a/src/AngleSharp.Js/Cache/DomShape.cs +++ b/src/AngleSharp.Js/Cache/DomShape.cs @@ -2,6 +2,7 @@ namespace AngleSharp.Js.Cache { using Jint.Native; using System; + using System.Collections.Generic; /// /// Everything the prototype of one DOM type is built from: the member layout the engine @@ -15,7 +16,7 @@ namespace AngleSharp.Js.Cache /// sealed class DomShape { - public DomShape(Type type, Type baseType, String name, JsObjectShape shape, DomIndexers indexers, ConstructorDefinition constructor) + public DomShape(Type type, Type baseType, String name, JsObjectShape shape, DomIndexers indexers, ConstructorDefinition constructor, IReadOnlyDictionary members) { Type = type; BaseType = baseType; @@ -23,6 +24,7 @@ public DomShape(Type type, Type baseType, String name, JsObjectShape shape, DomI Shape = shape; Indexers = indexers; Constructor = constructor; + Members = members; } /// @@ -57,5 +59,11 @@ public DomShape(Type type, Type baseType, String name, JsObjectShape shape, DomI /// not exposed as one under any name the engine's libraries define. /// public ConstructorDefinition Constructor { get; } + + /// + /// Gets the members of the type, including the inherited ones, which are not declared by + /// the layout if the prototype above has them. + /// + public IReadOnlyDictionary Members { get; } } } diff --git a/src/AngleSharp.Js/Cache/DomShapeCache.cs b/src/AngleSharp.Js/Cache/DomShapeCache.cs index 4d9a466a..27d96043 100644 --- a/src/AngleSharp.Js/Cache/DomShapeCache.cs +++ b/src/AngleSharp.Js/Cache/DomShapeCache.cs @@ -40,7 +40,7 @@ public static DomShape GetOrCreate(Type type, LibrarySet libs, Engine engine) // Built outside GetOrAdd: the factory overload taking state is not available on // netstandard2.0, and a closure per call would be paid by every lookup, hit or not. - return _shapes.GetOrAdd(key, new DomShapeBuilder(type, libs, GetReservedNames(engine)).Build()); + return _shapes.GetOrAdd(key, new DomShapeBuilder(type, libs, GetReservedNames(engine)).Build(engine)); } /// @@ -141,14 +141,16 @@ public DomShapeBuilder(Type type, LibrarySet libs, HashSet reserved) _perRealmMethods = typeof(IWindow).GetTypeInfo().IsAssignableFrom(type.GetTypeInfo()); } - public DomShape Build() + public DomShape Build(Engine engine) { SetAllMembers(_type); + SetAddedOverloads(); SetExtensionMembers(); SetStringifier(); var constructor = _type.GetConstructorDefinition(_libs.Assemblies); var builder = new JsObjectShape.Builder(); + var inherited = GetInheritedMembers(engine); if (_name != null) { @@ -166,7 +168,18 @@ public DomShape Build() continue; } - _members[name].Declare(builder, name, _perRealmMethods); + var member = _members[name]; + + // What the prototype above holds is found there, so that patching a member of + // Element.prototype reaches a div, as pages (polyfills in particular) expect. + if (inherited == null || !inherited.TryGetValue(name, out var other) || !member.IsSameAs(other)) + { + member.Declare(builder, name, _perRealmMethods); + } + else if (_perRealmMethods) + { + member.DeclareInherited(builder, name); + } } if (constructor != null) @@ -181,7 +194,13 @@ public DomShape Build() typeof(IStringMap).IsAssignableFrom(_type) ? DomIndexers.ToDatasetName : null) : null; - return new DomShape(_type, _baseType, _name, builder.Build(), indexers, constructor); + return new DomShape(_type, _baseType, _name, builder.Build(), indexers, constructor, _members); + } + + private IReadOnlyDictionary GetInheritedMembers(Engine engine) + { + var type = _baseType.GetDomPrototypeType(_libs.Assemblies); + return type != typeof(Object) && type != _type ? DomShapeCache.GetOrCreate(type, _libs, engine).Members : null; } /// @@ -195,6 +214,37 @@ private static JsValue CreateConstructor(ObjectInstance prototype) return state != null ? state.GetConstructor(state.Shape.Constructor) : JsValue.Undefined; } + /// + /// A library can add overloads to a method of the DOM (e.g. addEventListener with options), + /// as an interface can't get them without breaking who implements it. The members found for a + /// type include the inherited ones, so that is about the names of everything it is derived from. + /// + private void SetAddedOverloads() + { + var names = new HashSet(_type.GetTypeTree() + .SelectMany(m => m.GetTypeInfo().GetCustomAttributes()) + .Select(m => m.OfficialName), StringComparer.Ordinal); + + foreach (var type in _libs.Assemblies.SelectMany(m => m.ExportedTypes)) + { + var typeInfo = type.GetTypeInfo(); + + if (typeInfo.IsAbstract && typeInfo.IsSealed && typeInfo.GetCustomAttributes().Any(m => names.Contains(m.Target))) + { + foreach (var entry in typeInfo.DeclaredMethods.GetExtensions()) + { + if (entry.Value.Other != null && _members.TryGetValue(entry.Key, out var declared) && declared is MethodMember member && member.IsOfInstance) + { + foreach (var overload in entry.Value.Overloads) + { + member.Add(overload); + } + } + } + } + } + } + private void SetExtensionMembers() { if (_name == null) @@ -438,9 +488,15 @@ private void Define(String name, Member member) /// /// One member of a DOM type, as much of it as is the same for every engine. /// - private abstract class Member + internal abstract class Member { public abstract void Declare(JsObjectShape.Builder builder, String name, Boolean perRealm); + + public abstract Boolean IsSameAs(Member other); + + // What the window inherits has to be there for the global object all the same. + public virtual void DeclareInherited(JsObjectShape.Builder builder, String name) => + Declare(builder, name, true); } private sealed class MethodMember : Member @@ -452,8 +508,30 @@ public MethodMember(params MethodInfo[] overloads) _overloads = overloads; } + // What was added doesn't count, so that adding goes on from there. + public Boolean IsOfInstance => _overloads.Any(m => !m.IsStatic); + public void Add(MethodInfo method) => _overloads = _overloads.Concat(new[] { method }).ToArray(); + public override Boolean IsSameAs(Member other) => + other is MethodMember method && _overloads.SequenceEqual(method._overloads); + + // A bare addEventListener(...) has no receiver to find its engine by, so the window + // needs a function of its own, which still asks EventTarget.prototype for the method: + // it may have been patched there. + public override void DeclareInherited(JsObjectShape.Builder builder, String name) => + builder.PerRealmSlot( + name, + prototype => + { + var instance = DomPrototypeState.Of(prototype).Instance; + return new ClrFunction(instance.Jint, name, (thisObject, arguments) => + instance.Jint.Call(prototype.Prototype.Get(name), thisObject.IsObject() ? thisObject : instance.Window, arguments)); + }, + enumerable: true, + writable: true, + configurable: true); + public override void Declare(JsObjectShape.Builder builder, String name, Boolean perRealm) { if (_overloads.Length > 1) @@ -471,9 +549,9 @@ public override void Declare(JsObjectShape.Builder builder, String name, Boolean builder.PerRealmSlot( name, prototype => Bind(prototype, name, method), - enumerable: false, - writable: false, - configurable: false); + enumerable: true, + writable: true, + configurable: true); } else { @@ -481,9 +559,9 @@ public override void Declare(JsObjectShape.Builder builder, String name, Boolean name, (thisObject, arguments) => EngineExtensions.CallShared(method, thisObject, arguments), length: 0, - enumerable: false, - writable: false, - configurable: false); + enumerable: true, + writable: true, + configurable: true); } } @@ -510,9 +588,9 @@ private void DeclareOverloads(JsObjectShape.Builder builder, String name, Boolea return new ClrFunction(instance.Jint, name, (thisObject, arguments) => instance.Call(overloads.SelectOverload(arguments), thisObject, arguments)); }, - enumerable: false, - writable: false, - configurable: false); + enumerable: true, + writable: true, + configurable: true); } else { @@ -520,9 +598,9 @@ private void DeclareOverloads(JsObjectShape.Builder builder, String name, Boolea name, (thisObject, arguments) => EngineExtensions.CallShared(overloads.SelectOverload(arguments), thisObject, arguments), length: 0, - enumerable: false, - writable: false, - configurable: false); + enumerable: true, + writable: true, + configurable: true); } } } @@ -542,6 +620,10 @@ public PropertyMember(MethodInfo getter, MethodInfo setter, DomPutForwardsAttrib _sameObject = sameObject; } + public override Boolean IsSameAs(Member other) => + other is PropertyMember property && _getter == property._getter && _setter == property._setter && + _putsForward == property._putsForward && _sameObject == property._sameObject; + // Both halves are declared even when only one accessor exists, because that is what // a pair of ClrFunctions over a null MethodInfo used to be: present, and answering // undefined. An accessor is reached through the object it is read from, so unlike a @@ -566,8 +648,8 @@ public override void Declare(JsObjectShape.Builder builder, String name, Boolean name, read, write, - enumerable: false, - configurable: false); + enumerable: true, + configurable: true); } private static JsValue Forward(MethodInfo getter, DomPutForwardsAttribute putsForward, JsValue thisObject, JsValue[] arguments) @@ -595,8 +677,11 @@ public EventMember(DomEventDefinition definition) _definition = definition; } + public override Boolean IsSameAs(Member other) => + other is EventMember member && _definition.IsSameAs(member._definition); + public override void Declare(JsObjectShape.Builder builder, String name, Boolean perRealm) => - builder.Accessor(name, _definition.GetHandler, _definition.SetHandler, enumerable: false, configurable: false); + builder.Accessor(name, _definition.GetHandler, _definition.SetHandler, enumerable: true, configurable: true); } } } diff --git a/src/AngleSharp.Js/Cache/PrototypeTypeCache.cs b/src/AngleSharp.Js/Cache/PrototypeTypeCache.cs index 70b08cb3..b2ddfdcb 100644 --- a/src/AngleSharp.Js/Cache/PrototypeTypeCache.cs +++ b/src/AngleSharp.Js/Cache/PrototypeTypeCache.cs @@ -1,6 +1,7 @@ namespace AngleSharp.Js.Cache { using AngleSharp.Attributes; + using AngleSharp.Dom; using System; using System.Collections.Concurrent; using System.Collections.Generic; @@ -44,7 +45,9 @@ public static Boolean IsDomType(this Type type) => _domTypes.GetOrAdd(type, static current => { var name = GetCanonicalName(current); - return name != null && !String.Equals(name, EventTargetName, StringComparison.Ordinal); + + // What script creates by new EventTarget() is all that the name stands for. + return name != null && (current == typeof(EventTarget) || !String.Equals(name, EventTargetName, StringComparison.Ordinal)); }); /// diff --git a/src/AngleSharp.Js/Dom/Crypto.cs b/src/AngleSharp.Js/Dom/Crypto.cs new file mode 100644 index 00000000..e71bdffc --- /dev/null +++ b/src/AngleSharp.Js/Dom/Crypto.cs @@ -0,0 +1,63 @@ +namespace AngleSharp.Js.Dom +{ + using AngleSharp.Attributes; + using AngleSharp.Dom; + using Jint; + using Jint.Native; + using Jint.Native.TypedArray; + using System; + using System.Security.Cryptography; + + /// + /// Represents the Crypto interface: random numbers, without the operations of crypto.subtle. + /// + [DomName("Crypto")] + public sealed class Crypto + { + private const Int32 MaxBytes = 65536; + + internal Crypto() + { + } + + /// + /// Fills the given array of integers with random numbers. + /// + /// The array to fill. + /// The given array. + [DomName("getRandomValues")] + public JsTypedArray GetRandomValues(JsTypedArray array) + { + if (array is null || array.IsFloat16Array() || array.IsFloat32Array() || array.IsFloat64Array()) + { + throw new DomException(DomError.TypeMismatch); + } + + if (array.Get("byteLength").AsNumber() > MaxBytes) + { + throw new DomException(DomError.QuotaExceeded); + } + + var bytes = new Byte[4]; + + using (var random = RandomNumberGenerator.Create()) + { + for (var i = 0; i < (Int32)array.Length; i++) + { + random.GetBytes(bytes); + // an integer array keeps what fits of the value, as for any assignment + array.Set(i, (Double)BitConverter.ToUInt32(bytes, 0)); + } + } + + return array; + } + + /// + /// Creates a random UUID (version 4). + /// + /// The UUID in lowercase. + [DomName("randomUUID")] + public String RandomUuid() => Guid.NewGuid().ToString("D"); + } +} diff --git a/src/AngleSharp.Js/Dom/CustomElementHost.cs b/src/AngleSharp.Js/Dom/CustomElementHost.cs new file mode 100644 index 00000000..472d4a9a --- /dev/null +++ b/src/AngleSharp.Js/Dom/CustomElementHost.cs @@ -0,0 +1,151 @@ +namespace AngleSharp.Js.Dom +{ + using AngleSharp.Attributes; + using AngleSharp.Dom; + using AngleSharp.Scripting; + using Jint; + using Jint.Native; + using Jint.Native.Function; + using Jint.Native.Object; + using Jint.Runtime; + using System; + using System.Collections.Generic; + using System.Linq; + + /// + /// The members of the registry that depend on what a script can tell apart. + /// + [DomExposed("CustomElementRegistry")] + public static class CustomElementRegistryExtensions + { + /// + /// Gets the constructor of the custom element with the given name, or else undefined. + /// + /// The registry. + /// The name of the element. + /// The constructor, if defined. + [DomName("get")] + public static JsValue Get(this CustomElementRegistry registry, String name) => + registry.Get(name) as JsValue ?? JsValue.Undefined; + } + + /// + /// Runs the constructors and callbacks of custom elements for the registry of the DOM. What makes + /// a custom element is what a constructor of the DOM does for it (see EngineInstance): the wrapper + /// of the existing element is what its constructor gets to see as this. + /// + sealed class CustomElementHost : ICustomElementHost + { + private static readonly String[] CallbackNames = { "connectedCallback", "disconnectedCallback", "adoptedCallback", "attributeChangedCallback" }; + + private readonly JsScriptingService _service; + + public CustomElementHost(JsScriptingService service) + { + _service = service; + } + + public CustomElementDescription Describe(IDocument document, Object constructor) + { + var engine = _service.GetOrCreateInstance(document); + var type = constructor as JsValue; + + if (type is null || !type.IsConstructor()) + { + throw new JavaScriptException(engine.Jint.Intrinsics.TypeError, "The constructor of a custom element has to be a constructor."); + } + + if (!(type.Get("prototype") is ObjectInstance prototype)) + { + throw new JavaScriptException(engine.Jint.Intrinsics.TypeError, "The prototype of a custom element has to be an object."); + } + + var callbacks = new Dictionary(); + + foreach (var name in CallbackNames) + { + var callback = prototype.Get(name); + + if (!callback.IsUndefined()) + { + callbacks[name] = callback.AsFunction(engine) ?? throw new JavaScriptException(engine.Jint.Intrinsics.TypeError, $"The {name} of a custom element has to be a function."); + } + } + + return new CustomElementDescription(GetObservedAttributes(type, engine, callbacks), callbacks); + } + + public Boolean Construct(IElement element, CustomElementDefinition definition) + { + var engine = _service.GetOrCreateInstance(element.Owner); + var type = (JsValue)definition.Constructor; + var wrapper = engine.GetDomNode(element); + var construction = engine.BeginCustomElementConstruction(element, definition); + + try + { + var result = engine.Jint.Construct(type); + + if (!ReferenceEquals(result, wrapper)) + { + throw new JavaScriptException(engine.Jint.Intrinsics.TypeError, "The constructor of a custom element did not give the element."); + } + + return true; + } + catch (JavaScriptException error) + { + engine.ReportError(error); + return false; + } + catch (Exception error) when (error is not OperationCanceledException) + { + // whatever else it is, this is in the middle of an operation of the DOM + element.Owner.Context.TrackError(error); + return false; + } + finally + { + engine.EndCustomElementConstruction(construction); + } + } + + public void Invoke(IDocument document, IElement element, Object callback, params Object[] arguments) + { + var engine = _service.GetOrCreateInstance(document); + engine.EnterScript(); + + try + { + ((Function)callback).Call(engine.GetDomNode(element), arguments.Select(m => m.ToJsValue(engine)).ToArray()); + } + catch (JavaScriptException error) + { + engine.ReportError(error); + } + catch (Exception error) when (error is not OperationCanceledException) + { + document.Context.TrackError(error); + } + finally + { + engine.LeaveScript(); + } + } + + private static IEnumerable GetObservedAttributes(JsValue type, EngineInstance engine, Dictionary callbacks) + { + if (callbacks.ContainsKey("attributeChangedCallback")) + { + var observed = type.Get("observedAttributes"); + + if (!observed.IsUndefined() && !observed.IsNull()) + { + return (String[])observed.As(typeof(String[]), engine); + } + } + + return Array.Empty(); + } + } +} diff --git a/src/AngleSharp.Js/Dom/DomExceptions.cs b/src/AngleSharp.Js/Dom/DomExceptions.cs new file mode 100644 index 00000000..27135f94 --- /dev/null +++ b/src/AngleSharp.Js/Dom/DomExceptions.cs @@ -0,0 +1,112 @@ +namespace AngleSharp.Js.Dom +{ + using Acornima.Ast; + using AngleSharp.Dom; + using Jint; + using Jint.Native; + using System; + + /// + /// The DOMException of scripts (WebIDL 3.14.1), which are errors as far as a script can tell: + /// they have a stack and are an instance of Error. That is why it is a script, too. + /// + static class DomExceptions + { + // The names by their legacy code; not every code has one anymore (WebIDL table 1). + private static readonly String[] Names = + { + null, + "IndexSizeError", + "DOMStringSizeError", + "HierarchyRequestError", + "WrongDocumentError", + "InvalidCharacterError", + "NoDataAllowedError", + "NoModificationAllowedError", + "NotFoundError", + "NotSupportedError", + "InUseAttributeError", + "InvalidStateError", + "SyntaxError", + "InvalidModificationError", + "NamespaceError", + "InvalidAccessError", + "ValidationError", + "TypeMismatchError", + "SecurityError", + "NetworkError", + "AbortError", + "URLMismatchError", + "QuotaExceededError", + "TimeoutError", + "InvalidNodeTypeError", + "DataCloneError", + }; + + private const String Source = @"(function () { + var codes = { + IndexSizeError: 1, HierarchyRequestError: 3, WrongDocumentError: 4, InvalidCharacterError: 5, + NoModificationAllowedError: 7, NotFoundError: 8, NotSupportedError: 9, InUseAttributeError: 10, + InvalidStateError: 11, SyntaxError: 12, InvalidModificationError: 13, NamespaceError: 14, + InvalidAccessError: 15, TypeMismatchError: 17, SecurityError: 18, NetworkError: 19, AbortError: 20, + URLMismatchError: 21, QuotaExceededError: 22, TimeoutError: 23, InvalidNodeTypeError: 24, DataCloneError: 25 + }; + var constants = { + INDEX_SIZE_ERR: 1, DOMSTRING_SIZE_ERR: 2, HIERARCHY_REQUEST_ERR: 3, WRONG_DOCUMENT_ERR: 4, + INVALID_CHARACTER_ERR: 5, NO_DATA_ALLOWED_ERR: 6, NO_MODIFICATION_ALLOWED_ERR: 7, NOT_FOUND_ERR: 8, + NOT_SUPPORTED_ERR: 9, INUSE_ATTRIBUTE_ERR: 10, INVALID_STATE_ERR: 11, SYNTAX_ERR: 12, + INVALID_MODIFICATION_ERR: 13, NAMESPACE_ERR: 14, INVALID_ACCESS_ERR: 15, VALIDATION_ERR: 16, + TYPE_MISMATCH_ERR: 17, SECURITY_ERR: 18, NETWORK_ERR: 19, ABORT_ERR: 20, URL_MISMATCH_ERR: 21, + QUOTA_EXCEEDED_ERR: 22, TIMEOUT_ERR: 23, INVALID_NODE_TYPE_ERR: 24, DATA_CLONE_ERR: 25 + }; + function DOMException(message, name) { + if (!new.target) { + throw new TypeError(""Constructor DOMException requires 'new'""); + } + var self = new Error(message === undefined ? '' : String(message)); + var given = name === undefined ? 'Error' : String(name); + Object.setPrototypeOf(self, new.target.prototype); + Object.defineProperty(self, 'name', { value: given, configurable: true }); + Object.defineProperty(self, 'code', { value: codes[given] || 0, configurable: true }); + return self; + } + DOMException.prototype = Object.create(Error.prototype, { + constructor: { value: DOMException, writable: true, configurable: true } + }); + Object.defineProperty(DOMException, 'prototype', { writable: false }); + Object.keys(constants).forEach(function (key) { + var constant = { value: constants[key], enumerable: true }; + Object.defineProperty(DOMException, key, constant); + Object.defineProperty(DOMException.prototype, key, constant); + }); + return DOMException; +})()"; + + // Reusable and thread-safe, like the scripts of pages (see ScriptCache). + private static readonly Lazy> Prepared = new Lazy>(() => Engine.PrepareScript(Source)); + + /// + /// Creates the constructor for the given engine, before any script of a page runs. + /// + public static JsValue CreateConstructor(Engine engine) + { + var prepared = Prepared.Value; + return engine.Evaluate(prepared); + } + + /// + /// Creates what a script gets for the exception of the DOM. The ones made from just a message + /// don't tell a code, and have the message as name. + /// + public static JsValue Create(Engine engine, JsValue constructor, DomException exception) + { + var code = exception.Code; + var isKnown = code > 0 && code < Names.Length && Names[code] != null; + var message = code == 0 ? exception.Name : exception.Message; + + // the names added to the standard later (e.g. NotAllowedError) have no code + var name = isKnown ? Names[code] : exception.Name.EndsWith("Error", StringComparison.Ordinal) ? exception.Name : "Error"; + return engine.Construct(constructor, new JsValue[] { message, name }); + } + } +} diff --git a/src/AngleSharp.Js/Dom/DomParser.cs b/src/AngleSharp.Js/Dom/DomParser.cs index 882ed45e..ffe50708 100644 --- a/src/AngleSharp.Js/Dom/DomParser.cs +++ b/src/AngleSharp.Js/Dom/DomParser.cs @@ -55,7 +55,9 @@ public IDocument Parse(String str, String type) try { - var task = factory.CreateAsync(ctx, new CreateDocumentOptions(response), default); + // What is parsed is just data: it doesn't replace the page, and doesn't run anything. + var options = new CreateDocumentOptions(response) { IsNavigationDisabled = true, IsScriptingDisabled = true }; + var task = factory.CreateAsync(ctx, options, default); var document = task.Result; return document; } diff --git a/src/AngleSharp.Js/Dom/EventAttributeObserver.cs b/src/AngleSharp.Js/Dom/EventAttributeObserver.cs index 444e7277..29e52f72 100644 --- a/src/AngleSharp.Js/Dom/EventAttributeObserver.cs +++ b/src/AngleSharp.Js/Dom/EventAttributeObserver.cs @@ -5,11 +5,15 @@ namespace AngleSharp.Js.Dom using AngleSharp.Scripting; using Jint.Native; using Jint.Native.Function; + using Jint.Runtime; using System; using System.Collections.Generic; + using System.Runtime.CompilerServices; sealed class EventAttributeObserver : IAttributeObserver { + private readonly ConditionalWeakTable> _handlers = + new ConditionalWeakTable>(); private readonly Dictionary> _observers; private readonly JsScriptingService _service; @@ -103,13 +107,38 @@ private void RegisterEventCallback(String eventName) return; } + // the attribute is the one handler of the event, as long as it is there + var handlers = _handlers.GetOrCreateValue(element); + + if (handlers.TryGetValue(eventName, out var previous)) + { + element.RemoveEventListener(eventName, previous); + handlers.Remove(eventName); + } + + if (value is null) + { + return; + } + var engine = _service.GetOrCreateInstance(document); var jint = engine.Jint; - var instance = jint.Intrinsics.Function.Construct(new JsValue[] { "event", value }, JsValue.Undefined); - if (instance is Function functor) + try + { + var instance = jint.Intrinsics.Function.Construct(new JsValue[] { "event", value }, JsValue.Undefined); + + if (instance is Function functor) + { + var handler = functor.ToEventHandler(engine); + handlers[eventName] = handler; + element.AddEventListener(eventName, handler); + } + } + catch (JavaScriptException error) { - element.AddEventListener(eventName, functor.ToListener(engine)); + // this is about the handler, not about what set the attribute + engine.ReportError(error); } } }); diff --git a/src/AngleSharp.Js/Dom/FrameRequestCallback.cs b/src/AngleSharp.Js/Dom/FrameRequestCallback.cs index 182a1868..db62007d 100644 --- a/src/AngleSharp.Js/Dom/FrameRequestCallback.cs +++ b/src/AngleSharp.Js/Dom/FrameRequestCallback.cs @@ -1,5 +1,6 @@ namespace AngleSharp.Js.Dom { + using AngleSharp.Attributes; using AngleSharp.Dom; using System; @@ -9,4 +10,43 @@ namespace AngleSharp.Js.Dom /// 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); + + /// + /// The callback of requestIdleCallback, receiving how much time it has. + /// + /// The window, which is the receiver of the call. + /// The time it has. + public delegate void IdleRequestCallback(IWindow window, IdleDeadline deadline); + + /// + /// Represents the time a callback of requestIdleCallback has. There is no knowing how busy the + /// page is going to be, so it is what a browser gives when nothing is waiting. + /// + [DomName("IdleDeadline")] + public sealed class IdleDeadline + { + // The longest period a browser gives, so that a page stays responsive (requestIdleCallback 1.3). + private const Double Budget = 50.0; + + private readonly Performance _performance; + private readonly Double _start; + + internal IdleDeadline(Performance performance) + { + _performance = performance; + _start = performance.Now(); + } + + /// + /// Gets if the callback is only called because its timeout has passed, which it never is. + /// + [DomName("didTimeout")] + public Boolean DidTimeout => false; + + /// + /// Gets the milliseconds the callback has left. + /// + [DomName("timeRemaining")] + public Double TimeRemaining() => Math.Max(0.0, Budget - (_performance.Now() - _start)); + } } diff --git a/src/AngleSharp.Js/Dom/Performance.cs b/src/AngleSharp.Js/Dom/Performance.cs index 6db287b1..2b3f71d7 100644 --- a/src/AngleSharp.Js/Dom/Performance.cs +++ b/src/AngleSharp.Js/Dom/Performance.cs @@ -1,6 +1,7 @@ namespace AngleSharp.Js.Dom { using AngleSharp.Attributes; + using AngleSharp.Dom; using Jint; using Jint.Native; using Jint.Native.Object; @@ -19,6 +20,7 @@ public sealed class Performance { private readonly Stopwatch _clock = Stopwatch.StartNew(); private readonly List _entries = new List(); + private readonly List _observers = new List(); private readonly Double _timeOrigin = (DateTime.UtcNow - new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc)).TotalMilliseconds; /// @@ -50,7 +52,7 @@ public PerformanceMark Mark(String name, Object options = null) } var mark = new PerformanceMark(name, start, detail); - _entries.Add(mark); + Add(mark); return mark; } @@ -95,7 +97,7 @@ public PerformanceMeasure Measure(String name, Object startOrOptions = null, Str var from = start ?? 0.0; var measure = new PerformanceMeasure(name, from, (end ?? Now()) - from, detail); - _entries.Add(measure); + Add(measure); return measure; } @@ -130,6 +132,43 @@ public PerformanceEntry[] GetEntriesByName(String name, String type = null) => [DomName("clearMeasures")] public void ClearMeasures(String name = null) => Clear(PerformanceMeasure.Type, name); + /// + /// Removes the entries of the loaded resources, of which none are kept here. + /// + [DomName("clearResourceTimings")] + public void ClearResourceTimings() + { + } + + /// + /// Sets how many entries of loaded resources are kept, which is none here. + /// + /// The number of entries. + [DomName("setResourceTimingBufferSize")] + public void SetResourceTimingBufferSize(Int32 maxSize = 0) + { + } + + internal void AddObserver(PerformanceObserver observer) + { + if (!_observers.Contains(observer)) + { + _observers.Add(observer); + } + } + + internal void RemoveObserver(PerformanceObserver observer) => _observers.Remove(observer); + + private void Add(PerformanceEntry entry) + { + _entries.Add(entry); + + foreach (var observer in _observers.ToArray()) + { + observer.Notify(entry); + } + } + private void Clear(String type, String name) => _entries.RemoveAll(m => m.EntryType == type && (name == null || m.Name == name)); @@ -140,7 +179,7 @@ private static PerformanceEntry[] Sorted(IEnumerable entries) private Double FindMark(String name) { var mark = _entries.LastOrDefault(m => m.EntryType == PerformanceMark.Type && m.Name == name); - return mark?.StartTime ?? throw new JavaScriptException(EngineInstance.Current.Jint.Construct("SyntaxError", $"There is no mark named '{name}'.")); + return mark?.StartTime ?? throw new DomException(DomError.Syntax); } private static void RequireName(String name) diff --git a/src/AngleSharp.Js/Dom/PerformanceObserver.cs b/src/AngleSharp.Js/Dom/PerformanceObserver.cs new file mode 100644 index 00000000..aedff9b1 --- /dev/null +++ b/src/AngleSharp.Js/Dom/PerformanceObserver.cs @@ -0,0 +1,188 @@ +namespace AngleSharp.Js.Dom +{ + using AngleSharp.Attributes; + using AngleSharp.Browser; + using AngleSharp.Dom; + using System; + using System.Collections.Generic; + using System.Linq; + + /// + /// The callback receiving what a performance observer found. + /// + /// The new entries. + /// The observer. + [DomName("PerformanceObserverCallback")] + public delegate void PerformanceObserverCallback(PerformanceObserverEntryList entries, PerformanceObserver observer); + + /// + /// Represents the options of observing. + /// + [DomName("PerformanceObserverInit")] + public sealed class PerformanceObserverOptions + { + /// + /// Gets or sets the types of entries to observe. + /// + [DomName("entryTypes")] + public String[] EntryTypes { get; set; } + + /// + /// Gets or sets the single type of entries to observe. + /// + [DomName("type")] + public String Type { get; set; } + + /// + /// Gets or sets if the entries existing already of that type are wanted as well. + /// + [DomName("buffered")] + public Boolean Buffered { get; set; } + } + + /// + /// Represents the new entries handed to the callback of a performance observer. + /// + [DomName("PerformanceObserverEntryList")] + public sealed class PerformanceObserverEntryList + { + private readonly PerformanceEntry[] _entries; + + internal PerformanceObserverEntryList(IEnumerable entries) + { + _entries = entries.OrderBy(m => m.StartTime).ToArray(); + } + + /// + /// Gets all of the entries. + /// + [DomName("getEntries")] + public PerformanceEntry[] GetEntries() => _entries.ToArray(); + + /// + /// Gets the entries of the given type. + /// + [DomName("getEntriesByType")] + public PerformanceEntry[] GetEntriesByType(String type) => _entries.Where(m => m.EntryType == type).ToArray(); + + /// + /// Gets the entries with the given name, and the type if given. + /// + [DomName("getEntriesByName")] + public PerformanceEntry[] GetEntriesByName(String name, String type = null) => + _entries.Where(m => m.Name == name && (type == null || m.EntryType == type)).ToArray(); + } + + /// + /// Represents the PerformanceObserver of the standard. Only the entries of the performance object + /// are known: asking for other types (e.g. paint) is fine, as with any type a browser doesn't + /// have, and just doesn't find anything. + /// + [DomName("PerformanceObserver")] + [DomExposed("Window")] + public sealed class PerformanceObserver + { + private readonly IWindow _window; + private readonly PerformanceObserverCallback _callback; + private readonly HashSet _types = new HashSet(StringComparer.Ordinal); + private readonly List _queue = new List(); + private Boolean _isScheduled; + + /// + /// Creates a new observer. + /// + /// The window of the observer. + /// The callback receiving the entries. + [DomConstructor] + public PerformanceObserver(IWindow window, PerformanceObserverCallback callback) + { + _window = window ?? throw new ArgumentNullException(nameof(window)); + _callback = callback ?? throw new ArgumentNullException(nameof(callback)); + } + + /// + /// Starts observing the given types of entries. + /// + /// What to observe. + [DomName("observe")] + public void Observe(PerformanceObserverOptions options) + { + var performance = _window.Performance(); + + if (options?.EntryTypes != null) + { + _types.Clear(); + _types.UnionWith(options.EntryTypes); + } + else if (options?.Type != null) + { + _types.Add(options.Type); + + if (options.Buffered) + { + foreach (var entry in performance.GetEntriesByType(options.Type)) + { + Notify(entry); + } + } + } + + performance.AddObserver(this); + } + + /// + /// Stops observing. + /// + [DomName("disconnect")] + public void Disconnect() + { + _window.Performance().RemoveObserver(this); + _types.Clear(); + _queue.Clear(); + } + + /// + /// Gets the entries the callback has not been told about yet, which it won't be anymore. + /// + [DomName("takeRecords")] + public PerformanceEntry[] TakeRecords() + { + var records = _queue.ToArray(); + _queue.Clear(); + return records; + } + + internal void Notify(PerformanceEntry entry) + { + if (_types.Contains(entry.EntryType)) + { + _queue.Add(entry); + + if (!_isScheduled) + { + _isScheduled = true; + var loop = _window.Document.Context.GetService(); + + if (loop != null) + { + loop.Enqueue(_ => Deliver(), TaskPriority.Normal); + } + else + { + Deliver(); + } + } + } + } + + private void Deliver() + { + _isScheduled = false; + + if (_queue.Count > 0) + { + _callback.Invoke(new PerformanceObserverEntryList(TakeRecords()), this); + } + } + } +} diff --git a/src/AngleSharp.Js/Dom/ScriptValueCloner.cs b/src/AngleSharp.Js/Dom/ScriptValueCloner.cs new file mode 100644 index 00000000..caf33ace --- /dev/null +++ b/src/AngleSharp.Js/Dom/ScriptValueCloner.cs @@ -0,0 +1,16 @@ +namespace AngleSharp.Js.Dom +{ + using AngleSharp.Dom; + using Jint.Native; + using System; + + /// + /// Tells the DOM what the values of script are, see . + /// + sealed class ScriptValueCloner : IScriptValueCloner + { + public Object Undefined => JsValue.Undefined; + + public Object Clone(Object value) => StructuredClone.Clone(value); + } +} diff --git a/src/AngleSharp.Js/Dom/StructuredClone.cs b/src/AngleSharp.Js/Dom/StructuredClone.cs index f39afff7..868c3630 100644 --- a/src/AngleSharp.Js/Dom/StructuredClone.cs +++ b/src/AngleSharp.Js/Dom/StructuredClone.cs @@ -29,6 +29,8 @@ private static Object Clone(Object value, HashSet path, Int32 depth) case Boolean: case Double: case Int32: + case DateTime: + case Byte[]: return value; case JsValue js when js is not ObjectInstance: return js.IsUndefined() || js.IsNull() ? null : Clone(js.ToObject(), path, depth); @@ -48,6 +50,11 @@ private static Object CloneObject(ObjectInstance source, HashSet path, I return date.ToObject(); } + if (source.IsBufferSource()) + { + return source.GetBytes(); + } + if (source is ArrayInstance array) { var items = new Object[(Int32)array.Get("length").AsNumber()]; diff --git a/src/AngleSharp.Js/Dom/TextEncoder.cs b/src/AngleSharp.Js/Dom/TextEncoder.cs new file mode 100644 index 00000000..f0edf487 --- /dev/null +++ b/src/AngleSharp.Js/Dom/TextEncoder.cs @@ -0,0 +1,218 @@ +namespace AngleSharp.Js.Dom +{ + using AngleSharp.Attributes; + using AngleSharp.Js; + using Jint; + using Jint.Native; + using Jint.Native.TypedArray; + using System; + using System.Text; + + /// + /// Represents the TextEncoder of the Encoding standard, which turns text into UTF-8. + /// + [DomName("TextEncoder")] + [DomExposed("Window")] + [DomExposed("Worker")] + public sealed class TextEncoder + { + // What can't be encoded (a lone surrogate) becomes U+FFFD, as asked for by the standard. + private static readonly Encoding Utf8 = new UTF8Encoding(false, false); + + /// + /// Creates a new encoder. + /// + [DomConstructor] + public TextEncoder() + { + } + + /// + /// Gets the name of the encoding, which is always utf-8. + /// + [DomName("encoding")] + public String Encoding => "utf-8"; + + /// + /// Encodes the text. + /// + /// The text to encode, if any. + /// The bytes of the text, which are a Uint8Array for scripts. + [DomName("encode")] + public ArraySegment Encode(String input = null) => + new ArraySegment(Utf8.GetBytes(input ?? String.Empty)); + + /// + /// Encodes as much of the text as fits into the given array, without splitting a character. + /// + /// The text to encode. + /// The Uint8Array to write to. + /// An object telling how many code units were read and how many bytes written. + [DomName("encodeInto")] + public Object EncodeInto(String source, JsTypedArray destination) + { + var jint = EngineInstance.Current.Jint; + var read = 0; + var written = 0; + + if (destination is { } target && target.IsUint8Array()) + { + var capacity = (Int32)target.Length; + var text = source ?? String.Empty; + + while (read < text.Length) + { + var length = Char.IsHighSurrogate(text[read]) && read + 1 < text.Length && Char.IsLowSurrogate(text[read + 1]) ? 2 : 1; + var bytes = Utf8.GetBytes(text.Substring(read, length)); + + if (written + bytes.Length > capacity) + { + break; + } + + foreach (var value in bytes) + { + target.Set(written++, (Int32)value); + } + + read += length; + } + } + + var result = new JsObject(jint); + result.Set("read", read); + result.Set("written", written); + return result; + } + } + + /// + /// Represents the options of a TextDecoder. + /// + [DomName("TextDecoderOptions")] + public sealed class TextDecoderOptions + { + /// + /// Gets or sets if invalid input is an error instead of becoming U+FFFD. + /// + [DomName("fatal")] + public Boolean Fatal { get; set; } + + /// + /// Gets or sets if a byte order mark stays in the text. + /// + [DomName("ignoreBOM")] + public Boolean IgnoreBom { get; set; } + } + + /// + /// Represents the options of a call to decode. + /// + [DomName("TextDecodeOptions")] + public sealed class TextDecodeOptions + { + /// + /// Gets or sets if more input follows, so an incomplete character at the end has to wait. + /// + [DomName("stream")] + public Boolean Stream { get; set; } + } + + /// + /// Represents the TextDecoder of the Encoding standard, which turns bytes into text. + /// + [DomName("TextDecoder")] + [DomExposed("Window")] + [DomExposed("Worker")] + public sealed class TextDecoder + { + private readonly Encoding _encoding; + private Decoder _decoder; + private Boolean _isBomHandled; + + /// + /// Creates a new decoder. + /// + /// The label of the encoding; by default utf-8. + /// The options, if any. + [DomConstructor] + public TextDecoder(String label = null, TextDecoderOptions options = null) + { + var name = String.IsNullOrWhiteSpace(label) ? "utf-8" : label.Trim(); + + if (!AngleSharp.Text.TextEncoding.IsSupported(name)) + { + throw new Jint.Runtime.JavaScriptException(EngineInstance.Current.Jint.Construct("RangeError", $"The encoding '{label}' is not supported.")); + } + + var encoding = AngleSharp.Text.TextEncoding.Resolve(name); + Fatal = options?.Fatal ?? false; + IgnoreBom = options?.IgnoreBom ?? false; + _encoding = Encoding.GetEncoding(encoding.WebName, new EncoderReplacementFallback(), Fatal ? DecoderFallback.ExceptionFallback : new DecoderReplacementFallback("�")); + } + + /// + /// Gets the name of the encoding. + /// + [DomName("encoding")] + public String EncodingName => _encoding.WebName; + + /// + /// Gets if invalid input is an error. + /// + [DomName("fatal")] + public Boolean Fatal { get; } + + /// + /// Gets if a byte order mark stays in the text. + /// + [DomName("ignoreBOM")] + public Boolean IgnoreBom { get; } + + /// + /// Decodes the given bytes. + /// + /// The bytes, if any: an ArrayBuffer or a view on one for scripts. + /// The options, if any. + /// The text. + [DomName("decode")] + public String Decode(Byte[] input = null, TextDecodeOptions options = null) + { + var isStreaming = options?.Stream ?? false; + var bytes = input ?? new Byte[0]; + var decoder = _decoder ?? (_decoder = _encoding.GetDecoder()); + String text; + + try + { + var chars = new Char[_encoding.GetMaxCharCount(bytes.Length)]; + var count = decoder.GetChars(bytes, 0, bytes.Length, chars, 0, !isStreaming); + text = new String(chars, 0, count); + } + catch (DecoderFallbackException) + { + _decoder = null; + _isBomHandled = false; + throw new Jint.Runtime.JavaScriptException(EngineInstance.Current.Jint.Intrinsics.TypeError, "The input is not valid."); + } + + if (!_isBomHandled && text.Length > 0) + { + _isBomHandled = true; + + if (!IgnoreBom && text[0] == '') + { + text = text.Substring(1); + } + } + + if (!isStreaming) + { + _decoder = null; + _isBomHandled = false; + } + + return text; + } + } +} diff --git a/src/AngleSharp.Js/Dom/WindowExtensions.cs b/src/AngleSharp.Js/Dom/WindowExtensions.cs index 9a243585..32d962f9 100644 --- a/src/AngleSharp.Js/Dom/WindowExtensions.cs +++ b/src/AngleSharp.Js/Dom/WindowExtensions.cs @@ -23,6 +23,8 @@ public static class WindowExtensions new ConditionalWeakTable(); private static readonly ConditionalWeakTable Performances = new ConditionalWeakTable(); + private static readonly ConditionalWeakTable Cryptos = + new ConditionalWeakTable(); // The interval between two animation frames of a 60 Hz display. private const Int32 FrameInterval = 16; @@ -116,6 +118,57 @@ internal static void RegisterWorkerWindow(IWindow workerWindow, Worker owner) WorkerOwners.Add(workerWindow, owner); } + /// + /// Gets a copy of a value that shares nothing with it (HTML 2.7), which fails with a + /// DataCloneError for what can't be copied, e.g. a function. + /// + /// The window. + /// The value to copy. + /// The options, of which transfer has no meaning here. + /// The copy. + [DomName("structuredClone")] + public static Object StructuredCloneOf(this IWindow window, Object value = null, Object options = null) => + StructuredClone.Clone(value); + + /// + /// Gets the origin of the window. + /// + /// The window. + /// The serialized origin. + [DomName("origin")] + [DomAccessor(Accessors.Getter)] + public static String GetOrigin(this IWindow window) => window.Document?.Origin ?? "null"; + + /// + /// Gets if the window is a secure context, which an https address and the local machine are. + /// + /// The window. + /// True if it is one. + [DomName("isSecureContext")] + [DomAccessor(Accessors.Getter)] + public static Boolean GetIsSecureContext(this IWindow window) + { + var address = window.Document?.DocumentUri; + + if (String.IsNullOrEmpty(address)) + { + return false; + } + + var url = new Url(address); + return url.Scheme == "https" || url.Scheme == "file" || url.HostName == "localhost" || url.HostName == "127.0.0.1" || url.HostName == "[::1]"; + } + + /// + /// Gets the number of frames in the window. + /// + /// The window. + /// The number of iframes and frames of its document. + [DomName("length")] + [DomAccessor(Accessors.Getter)] + public static Int32 GetLength(this IWindow window) => + window.Document?.QuerySelectorAll("iframe,frame").Length ?? 0; + /// /// Gets the parent window context. /// @@ -194,6 +247,41 @@ public static void CancelAnimationFrame(this IWindow window, Int32 handle) window.ClearTimeout(handle); } + /// + /// Gets the crypto object of the window. + /// + /// + /// The crypto object. + [DomName("crypto")] + [DomAccessor(Accessors.Getter)] + public static Crypto Crypto(this IWindow window) => Cryptos.GetValue(window, _ => new Crypto()); + + /// + /// Requests the callback to be called when there is time for it, which is as soon as what is + /// waiting on the event loop right now is done. + /// + /// + /// The callback receiving how much time it has. + /// The options, whose timeout isn't needed by running that soon. + /// The handle to cancel the request with. + [DomName("requestIdleCallback")] + public static Int32 RequestIdleCallback(this IWindow window, IdleRequestCallback callback, Object options = null) + { + var performance = window.Performance(); + return window.SetTimeout(w => callback?.Invoke(w, new IdleDeadline(performance)), 1); + } + + /// + /// Cancels a request made with requestIdleCallback. + /// + /// + /// The handle of the request. + [DomName("cancelIdleCallback")] + public static void CancelIdleCallback(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 diff --git a/src/AngleSharp.Js/Dom/Worker.cs b/src/AngleSharp.Js/Dom/Worker.cs index c3c4a584..ce34258e 100644 --- a/src/AngleSharp.Js/Dom/Worker.cs +++ b/src/AngleSharp.Js/Dom/Worker.cs @@ -42,8 +42,20 @@ public sealed class Worker : EventTarget /// /// Creates a new worker and starts the worker script in a separate context. /// - [DomConstructor] public Worker(IWindow window, String source) + : this(window, source, null) + { + } + + /// + /// Creates a new worker and starts the worker script in a separate context. If that fails + /// it is told by an error event, as it happens later. + /// + /// The window creating the worker. + /// The address of the script. + /// The options, e.g. that the script is a module. + [DomConstructor] + public Worker(IWindow window, String source, WorkerOptions options) { if (window == null) { @@ -55,6 +67,13 @@ public Worker(IWindow window, String source) throw new ArgumentException("The worker source URL cannot be empty.", nameof(source)); } + var isModule = options?.Type == "module"; + + if (!isModule && options?.Type != null && options.Type != "classic") + { + throw new ArgumentException($"The type '{options.Type}' is not known.", nameof(options)); + } + _window = window; _windowLoop = _window.Document.Context.GetService(); @@ -71,7 +90,9 @@ public Worker(IWindow window, String source) { var workerConfig = Configuration.Default .With(_scripting) - .WithOnly(_workerLoop); + .WithOnly(_workerLoop) + .With(_ => parentContext.GetService()) + .With(_ => parentContext.GetService()); foreach (var service in parentContext.OriginalServices) { @@ -87,11 +108,12 @@ public Worker(IWindow window, String source) { try { - InitializeWorker(workerUrl); + InitializeWorker(workerUrl, isModule); } catch (Exception ex) { _startupError = ex; + Enqueue(_windowLoop, TaskPriority.Normal, () => ReportStartupError(workerUrl, ex)); } }); } @@ -116,6 +138,16 @@ public Worker(IWindow window, String source) internal Exception StartupError => _startupError; + /// + /// Raised when the script of the worker could not be started. + /// + [DomName("onerror")] + public event DomEventHandler Failed + { + add { AddEventListener(EventNames.Error, value); } + remove { RemoveEventListener(EventNames.Error, value); } + } + internal Boolean IsInitialized => _initialized; internal Object EvaluateInWorker(String source) @@ -157,9 +189,10 @@ public void PostMessage(Object message) return; } + // it had its error event, and what is sent to it goes nowhere if (_startupError != null) { - throw _startupError; + return; } if (!_initialized) @@ -205,13 +238,23 @@ internal void PostMessageToOwner(Object message) Enqueue(_windowLoop, TaskPriority.Normal, () => Dispatch(ev)); } - private void InitializeWorker(Url sourceUrl) + private void ReportStartupError(Url source, Exception error) + { + if (!_terminated) + { + var ev = new ErrorEvent(); + ev.Init(source.Href, 0, 0, error); + Dispatch(ev); + } + } + + private void InitializeWorker(Url sourceUrl, Boolean isModule) { - _workerDocument = _workerContext.OpenAsync(request => request.Content("")).GetAwaiter().GetResult(); + _workerDocument = _workerContext.OpenAsync(request => request.Address(sourceUrl).Content("")).GetAwaiter().GetResult(); WindowExtensions.RegisterWorkerWindow(_workerDocument.DefaultView, this); var source = FetchWorkerScript(sourceUrl); _scripting.EvaluateScript(_workerDocument, WorkerBootstrap, MimeTypeNames.DefaultJavaScript, sourceUrl.Href); - _scripting.EvaluateScript(_workerDocument, source, MimeTypeNames.DefaultJavaScript, sourceUrl.Href); + _scripting.EvaluateScript(_workerDocument, source, isModule ? "module" : MimeTypeNames.DefaultJavaScript, sourceUrl.Href); _initialized = true; @@ -325,4 +368,23 @@ private static void Enqueue(IEventLoop loop, TaskPriority priority, Action actio #endregion } + + /// + /// Represents the options of creating a worker. + /// + [DomName("WorkerOptions")] + public sealed class WorkerOptions + { + /// + /// Gets or sets the type of the script, i.e. classic (the default) or module. + /// + [DomName("type")] + public String Type { get; set; } + + /// + /// Gets or sets the name of the worker, for debugging. + /// + [DomName("name")] + public String Name { get; set; } + } } diff --git a/src/AngleSharp.Js/EngineInstance.cs b/src/AngleSharp.Js/EngineInstance.cs index 96ec0628..7de02017 100644 --- a/src/AngleSharp.Js/EngineInstance.cs +++ b/src/AngleSharp.Js/EngineInstance.cs @@ -14,19 +14,42 @@ namespace AngleSharp.Js using Jint.Native.Json; using Jint.Native.Object; using Jint.Native.Promise; + using Jint.Native.Symbol; using Jint.Runtime; using Jint.Runtime.Descriptors; using Jint.Runtime.Interop; using System; + using System.Collections; using System.Collections.Generic; using System.Reflection; using System.Runtime.CompilerServices; using System.Threading.Tasks; + /// + /// An element whose constructor is running. + /// + sealed class CustomElementConstruction + { + public CustomElementConstruction(IElement element, CustomElementDefinition definition) + { + Element = element; + Definition = definition; + } + + public IElement Element { get; } + + public CustomElementDefinition Definition { get; } + + public Boolean IsDone { get; set; } + } + sealed class EngineInstance { #region Fields + private Int32 _scriptDepth; + private const String ScriptValueKey = "AngleSharp.ScriptValue"; + // Jint's StackGuard.Disabled, which is internal. private const Int32 StackGuardDisabled = -1; @@ -39,6 +62,11 @@ sealed class EngineInstance private readonly DomNodeInstance _window; private readonly IDocument _document; private readonly JsImportMap _importMap; + private readonly ConditionalWeakTable _domExceptions = new ConditionalWeakTable(); + private readonly Stack _customElementConstructions = new Stack(); + private JsValue _domException; + private JsValue _lastReportedError; + private Int32 _inlineModules; private readonly List> _unhandledRejections = new List>(); #endregion @@ -50,6 +78,7 @@ public EngineInstance(IWindow window, IDictionary assignments, I _importMap = new JsImportMap(); var engineOptions = new Options(); engineOptions.EnableModules(new JsModuleLoader(this, window.Document, false)); + engineOptions.UseHostFactory(_ => new JsHost(window.Document)); // The handler uses the caches initialized below. The creator must return the // engine before DOM objects are wrapped or page scripts are evaluated. engineOptions.SetWrapObjectHandler(WrapObject); @@ -81,6 +110,8 @@ public EngineInstance(IWindow window, IDictionary assignments, I this.AddInstances(_window, lib); } + _window.FastSetProperty("DOMException", CreateDomExceptionProperty()); + foreach (var property in _window.GetOwnProperties()) { _engine.Global.FastSetProperty(property.Key.ToString(), AsGlobalProperty(property.Value)); @@ -146,12 +177,11 @@ public JsValue CreatePromise(Task task) { if (completed.IsFaulted) { - var error = completed.Exception.GetBaseException(); - reject(_engine.Intrinsics.Error.Construct(error.Message)); + reject(ToScriptValue(completed.Exception.GetBaseException())); } else if (completed.IsCanceled) { - reject(_engine.Intrinsics.Error.Construct("The operation was aborted.")); + reject(GetDomException(new DomException(DomError.Abort))); } else if (hasResult) { @@ -209,7 +239,11 @@ private void NotifyUnhandledRejections() foreach (var rejection in rejections) { - NotifyUnhandledRejection(rejection.Key, rejection.Value); + // an error in a module also rejects the promise of its evaluation + if (!ReferenceEquals(rejection.Value, _lastReportedError)) + { + NotifyUnhandledRejection(rejection.Key, rejection.Value); + } } } } @@ -237,14 +271,105 @@ private void NotifyUnhandledRejection(JsValue promise, JsValue reason) } } + /// + /// Runs what script gave as a callback whose result is waited for (e.g. of a lock). It is + /// called from anywhere, so it runs on the event loop as the engine isn't thread-safe, and + /// the task follows the promise the function may give. + /// + public Task RunCallback(Function function, Object receiver, Object[] arguments) + { + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + Settle(() => + { + try + { + var values = new JsValue[arguments.Length]; + + for (var i = 0; i < values.Length; i++) + { + values[i] = arguments[i].ToJsValue(this); + } + + var result = function.Call(receiver.ToJsValue(this), values); + var then = result is ObjectInstance candidate ? candidate.Get("then") : JsValue.Undefined; + + if (then.IsCallable()) + { + _engine.Call(then, result, new JsValue[] + { + new ClrFunction(_engine, "resolve", (_, values) => + { + completion.TrySetResult(values.Length > 0 ? values[0] : JsValue.Undefined); + return JsValue.Undefined; + }), + new ClrFunction(_engine, "reject", (_, values) => + { + completion.TrySetException(new JavaScriptException(values.Length > 0 ? values[0] : JsValue.Undefined)); + return JsValue.Undefined; + }), + }); + } + else + { + completion.TrySetResult(result); + } + } + catch (JavaScriptException thrown) + { + completion.TrySetException(thrown); + } + }); + + return completion.Task; + } + + /// + /// Tells that the host is about to run script, e.g. a timer or a listener. + /// + public void EnterScript() => _scriptDepth++; + + /// + /// Tells that the host ran script. When no other script is running anymore the promise + /// jobs it left are due, which is the microtask checkpoint of HTML 8.1.4.4. Script calling + /// into the DOM, e.g. by dispatchEvent, keeps them waiting until it is done itself. + /// + public void LeaveScript() + { + if (_scriptDepth == 1) + { + lock (_engine) + { + try + { + _engine.Advanced.ProcessTasks(); + } + catch (JavaScriptException thrown) + { + ReportError(thrown); + } + } + } + + _scriptDepth--; + } + private void Settle(Action settle) { void Run() { lock (_engine) { - settle(); - _engine.Advanced.ProcessTasks(); + EnterScript(); + + try + { + settle(); + } + finally + { + LeaveScript(); + } } } @@ -314,11 +439,15 @@ public JsValue GetSameObject(Object owner, MethodInfo getter) } var cache = _sameObjects.GetValue(owner, _ => new SameObjectCache()); - return cache.GetOrUpdate(getter, current, () => CreateInstance(current, getter.ReturnType)); + + // The interface of a collection doesn't tell that it is one, the class does: script + // iterates over document.links or element.children like any other. + var type = current is ITokenList || current.GetType().GetGenericInterface(typeof(IHtmlCollection<>)) != null ? current.GetType() : getter.ReturnType; + return cache.GetOrUpdate(getter, current, () => CreateInstance(current, type)); } - catch (TargetInvocationException) + catch (TargetInvocationException ex) { - throw new JavaScriptException(_engine.Intrinsics.Error); + throw ToScriptError(ex); } } @@ -335,6 +464,101 @@ public DomConstructorInstance GetDomConstructor(ConstructorDefinition definition return DomPrototypeState.Of(prototype).GetConstructor(definition); } + /// + /// Starts running the constructor for an existing element: the first constructor of the DOM + /// reached by it has to give that element. + /// + public CustomElementConstruction BeginCustomElementConstruction(IElement element, CustomElementDefinition definition) + { + var construction = new CustomElementConstruction(element, definition); + _customElementConstructions.Push(construction); + return construction; + } + + public void EndCustomElementConstruction(CustomElementConstruction construction) + { + if (_customElementConstructions.Count > 0 && ReferenceEquals(_customElementConstructions.Peek(), construction)) + { + _customElementConstructions.Pop(); + } + } + + /// + /// What a constructor of the DOM does for a class deriving from it (HTML 3.2.3): that is a + /// custom element, or else nobody may create it. The element is the one being upgraded, or a + /// new one for a script creating it with new, and it has the prototype of the class. + /// + public ObjectInstance ConstructCustomElement(JsValue newTarget) + { + var window = (IWindow)_window.Value; + var definition = window.CustomElements().FindDefinition(newTarget) + ?? throw new JavaScriptException(_engine.Intrinsics.TypeError, "Illegal constructor."); + ObjectInstance wrapper; + + if (_customElementConstructions.Count > 0 && ReferenceEquals(_customElementConstructions.Peek().Definition, definition)) + { + var construction = _customElementConstructions.Peek(); + + if (construction.IsDone) + { + throw new JavaScriptException(_engine.Intrinsics.TypeError, "The element has been constructed already."); + } + + construction.IsDone = true; + wrapper = GetDomNode(construction.Element); + } + else + { + wrapper = GetDomNode(window.CustomElements().CreateElement(definition)); + } + + if (newTarget.Get("prototype") is ObjectInstance prototype) + { + wrapper.Prototype = prototype; + } + + return wrapper; + } + + /// + /// Gets what a script sees of a member of the DOM failing: what the member threw for it, a + /// DOMException, or else an error with the message of what went wrong. + /// + public JavaScriptException ToScriptError(TargetInvocationException error) + { + return error.InnerException is JavaScriptException thrown + ? thrown + : new JavaScriptException(ToScriptValue(error.InnerException)); + } + + /// + /// Gets what a script gets to see of an exception: what was thrown for it, a DOMException, + /// or else an error with the message of what went wrong. + /// + public JsValue ToScriptValue(Exception error) + { + if (error is JavaScriptException thrown) + { + return thrown.Error; + } + + // an exception can't be just any value, so what wants to throw one puts it there + if (error != null && error.Data.Contains(ScriptValueKey)) + { + return error.Data[ScriptValueKey].ToJsValue(this); + } + + return error is DomException exception + ? GetDomException(exception) + : _engine.Intrinsics.Error.Construct(error?.Message ?? String.Empty); + } + + /// + /// Gets the DOMException of a script for the exception, which is the same object each time. + /// + public JsValue GetDomException(DomException exception) => + _domExceptions.GetValue(exception, key => DomExceptions.Create(_engine, GetDomException(), key)); + /// /// Reports an error nobody caught (HTML 8.1.4.7): the window gets to handle it, otherwise the /// browsing context gets to know of it. An error thrown while the window handles one isn't @@ -343,6 +567,7 @@ public DomConstructorInstance GetDomConstructor(ConstructorDefinition definition public void ReportError(JavaScriptException error) { var window = (IWindow)_window.Value; + _lastReportedError = error.Error; if (!_isReportingError) { @@ -378,6 +603,20 @@ public JsValue RunScript(String source, String type, String sourceUrl) => /// Runs the script, of which the position in what it is written in is given, if any. /// public JsValue RunScript(String source, String type, String sourceUrl, Position position) + { + EnterScript(); + + try + { + return RunScriptCore(source, type, sourceUrl, position); + } + finally + { + LeaveScript(); + } + } + + private JsValue RunScriptCore(String source, String type, String sourceUrl, Position position) { if (string.IsNullOrEmpty(type)) { @@ -399,10 +638,13 @@ public JsValue RunScript(String source, String type, String sourceUrl, Position } else if (type.Isi("module")) { - // use a unique specifier to import the module into Jint - var specifier = sourceUrl ?? Guid.NewGuid().ToString(); + // the specifier imports the module into Jint, so one without an address gets a unique one + var specifier = sourceUrl ?? CreateInlineSpecifier(); - return ImportModule(specifier, source); + // Jint has no options for the position of a module, but only spaces go before + var padding = position.Line > 0 ? new String('\n', position.Line - 1) + new String(' ', position.Column) : String.Empty; + + return ImportModule(specifier, padding + source); } else { @@ -467,8 +709,44 @@ private JsValue LoadImportMap(String source) return JsValue.Undefined; } + // Most pages never get to see one, and a script counts against the constraints of an engine, + // which may be strict, so it is only run when asked for. A page may replace it, as any global. + private PropertyDescriptor CreateDomExceptionProperty() + { + var replacement = default(JsValue); + var isReplaced = false; + var getter = new ClrFunction(_engine, "DOMException", (self, arguments) => isReplaced ? replacement : GetDomException()); + var setter = new ClrFunction(_engine, "DOMException", (self, arguments) => + { + replacement = arguments.Length > 0 ? arguments[0] : JsValue.Undefined; + isReplaced = true; + return JsValue.Undefined; + }); + return new GetSetPropertyDescriptor(getter, setter, false, true); + } + + private JsValue GetDomException() => _domException ?? (_domException = DomExceptions.CreateConstructor(_engine)); + + private String CreateInlineSpecifier() + { + var baseUri = _document.BaseUri; + + if (Uri.TryCreate(baseUri, UriKind.Absolute, out var uri) && uri.Scheme.IsOneOf(ProtocolNames.Http, ProtocolNames.Https)) + { + return $"{uri.AbsoluteUri}{JsHost.InlineModuleMark}{++_inlineModules}"; + } + + return Guid.NewGuid().ToString(); + } + private JsValue ImportModule(String specifier, String source) { + // what the loader resolves it to is what the module is found by + if (Uri.TryCreate(specifier, UriKind.Absolute, out var uri) && uri.Scheme.IsOneOf(ProtocolNames.Http, ProtocolNames.Https)) + { + specifier = uri.AbsoluteUri; + } + _engine.Modules.Add(specifier, source); _engine.Modules.Import(specifier); @@ -482,10 +760,30 @@ private JsValue ImportModule(String specifier, String source) // The members of the window are configurable (and its operations and interface objects // writable) per WebIDL, so that a page's own global replaces them as in a browser, e.g. // var item = ..., var open = ... or var Node = ...; an accessor like name keeps its setter. + // A descriptor that is fine as it is stays shared: window.Node = ... also changes Node. private static PropertyDescriptor AsGlobalProperty(PropertyDescriptor descriptor) => descriptor.IsAccessorDescriptor() ? new GetSetPropertyDescriptor(descriptor.Get, descriptor.Set, descriptor.Enumerable, true) - : new PropertyDescriptor(descriptor.Value, true, descriptor.Enumerable, true); + : descriptor.Writable && descriptor.Configurable + ? descriptor + : new PropertyDescriptor(descriptor.Value, true, descriptor.Enumerable, true); + + // A WebIDL iterable (e.g. Headers) is iterated like its entries, which a type giving + // entries has no other way to say. A collection is iterated by its proxy, see there. + private void SetIterator(ObjectInstance prototype, DomShape shape) + { + if (shape.Members.ContainsKey("entries") && shape.Indexers == null && !typeof(IEnumerable).IsAssignableFrom(shape.Type)) + { + prototype.FastSetProperty(GlobalSymbolRegistry.Iterator, new PropertyDescriptor( + new ClrFunction(_engine, "[Symbol.iterator]", (thisObject, arguments) => + { + var entries = thisObject.AsObject().Get("entries"); + var list = _engine.Call(entries, thisObject, Arguments.Empty); + return _engine.Call(list.AsObject().Get(GlobalSymbolRegistry.Iterator), list, Arguments.Empty); + }), + true, false, true)); + } + } // A collection is projected by the array-like proxy, which lets the engine read its // indices and length directly; everything else by the ordinary one. The decision is a @@ -523,6 +821,7 @@ private ObjectInstance CreatePrototype(Type type) var shape = DomShapeCache.GetOrCreate(type, _libs, _engine); var prototype = shape.Shape.Instantiate(_engine, GetParentPrototype(type, shape.BaseType)); JsObjectShape.SetHostState(prototype, new DomPrototypeState(this, prototype, shape)); + SetIterator(prototype, shape); return prototype; } diff --git a/src/AngleSharp.Js/Extensions/DomDelegates.cs b/src/AngleSharp.Js/Extensions/DomDelegates.cs index 5b02fc1a..48591259 100644 --- a/src/AngleSharp.Js/Extensions/DomDelegates.cs +++ b/src/AngleSharp.Js/Extensions/DomDelegates.cs @@ -1,17 +1,21 @@ namespace AngleSharp.Js { using AngleSharp.Dom; + using AngleSharp.Dom.Events; using Jint; using Jint.Native; using Jint.Native.Function; + using Jint.Native.Object; using Jint.Runtime; using Jint.Runtime.Interop; using System; using System.Collections.Concurrent; + using System.Collections.Generic; using System.Linq; using System.Linq.Expressions; using System.Reflection; using System.Runtime.CompilerServices; + using System.Threading.Tasks; static class DomDelegates { @@ -31,6 +35,7 @@ static class DomDelegates private static readonly ConditionalWeakTable _callables = new ConditionalWeakTable(); + private static readonly ConditionalWeakTable> _callbackObjects = new ConditionalWeakTable>(); private static readonly ConditionalWeakTable _listeners = new ConditionalWeakTable(); @@ -57,6 +62,46 @@ public static Function AsFunction(this JsValue value, EngineInstance engine) }); } + /// + /// Gets the function standing in for an object as a WebIDL callback interface (i.e. having + /// handleEvent as a listener, or acceptNode as a filter), if the type is one. It asks the + /// object for the method each time, as the standard says, and is the same one each time, so + /// that a listener can be removed again. + /// + public static Function AsCallbackObject(this ObjectInstance target, Type type, EngineInstance engine) + { + var name = type == typeof(DomEventHandler) ? "handleEvent" : type == typeof(NodeFilter) ? "acceptNode" : null; + + if (name == null) + { + return null; + } + + var functions = _callbackObjects.GetOrCreateValue(target); + + lock (functions) + { + if (!functions.TryGetValue(name, out var function)) + { + var jint = engine.Jint; + function = new ClrFunction(jint, name, (self, arguments) => + { + var method = target.Get(name); + + if (!method.IsCallable()) + { + throw new JavaScriptException(jint.Intrinsics.TypeError, $"The object has no function {name}."); + } + + return jint.Call(method, target, arguments); + }); + functions[name] = function; + } + + return function; + } + } + public static Delegate ToDelegate(this Type type, Function function, EngineInstance engine) { if (type != typeof(DomEventHandler)) @@ -80,6 +125,8 @@ private static DomEventHandler CreateListener(this Function function, EngineInst var objAsJs = obj.ToJsValue(engine); var evAsJs = ev.ToJsValue(engine); + engine.EnterScript(); + try { function.Call(objAsJs, new[] { evAsJs }); @@ -88,6 +135,58 @@ private static DomEventHandler CreateListener(this Function function, EngineInst { engine.ReportError(jsException); } + finally + { + engine.LeaveScript(); + } + }; + + /// + /// Gets what runs an event handler, i.e., an onclick and the like (HTML 8.1.8.1). Other than + /// a listener, it can cancel the event by its result. + /// + public static DomEventHandler ToEventHandler(this Function function, EngineInstance engine) => (obj, ev) => + { + var sender = obj.ToJsValue(engine); + engine.EnterScript(); + + try + { + // the onerror of a window is the one handler not getting the event + if (obj is IWindow && ev is ErrorEvent error) + { + var handled = function.Call(sender, new[] + { + (error.Message ?? String.Empty).ToJsValue(engine), + (error.FileName ?? String.Empty).ToJsValue(engine), + error.Line.ToJsValue(engine), + error.Column.ToJsValue(engine), + error.Error.ToJsValue(engine), + }); + + if (handled.IsBoolean() && handled.AsBoolean()) + { + ev.Cancel(); + } + } + else + { + var result = function.Call(sender, new[] { ev.ToJsValue(engine) }); + + if (result.IsBoolean() && !result.AsBoolean()) + { + ev.Cancel(); + } + } + } + catch (JavaScriptException thrown) + { + engine.ReportError(thrown); + } + finally + { + engine.LeaveScript(); + } }; public static T ToCallback(this Function function, EngineInstance engine) @@ -120,6 +219,19 @@ private static Func CreateFactory() Expression ToJsValue(ParameterExpression m) => Expression.Call(convert, Expression.Convert(m, typeof(Object)), engineExpr); var hasReceiver = parameters.Length > 0 && typeof(IWindow).IsAssignableFrom(parameters[0].Type); + var callback = methodInfo.ReturnType == typeof(Task) || methodInfo.ReturnType == typeof(Task) + ? Expression.Lambda(Expression.Convert( + Expression.Call(engineExpr, nameof(EngineInstance.RunCallback), Type.EmptyTypes, + objExpr, + hasReceiver ? Expression.Convert(parameters[0], typeof(Object)) : Expression.Constant(null, typeof(Object)), + Expression.NewArrayInit(typeof(Object), parameters.Skip(hasReceiver ? 1 : 0).Select(m => (Expression)Expression.Convert(m, typeof(Object))))), + methodInfo.ReturnType), parameters) + : CreateCallback(methodInfo, objExpr, engineExpr, parameters, hasReceiver, ToJsValue); + return Expression.Lambda>(callback, objExpr, engineExpr).Compile(); + } + + private static Expression CreateCallback(MethodInfo methodInfo, ParameterExpression objExpr, ParameterExpression engineExpr, ParameterExpression[] parameters, Boolean hasReceiver, Func ToJsValue) + { var call = Expression.Call(objExpr, "Call", new Type[0], new Expression[] { hasReceiver ? ToJsValue(parameters[0]) : Expression.Constant(JsValue.Undefined, typeof(JsValue)), @@ -133,9 +245,14 @@ Expression ToJsValue(ParameterExpression m) => ? Expression.TryCatch( Expression.Block(typeof(void), call), Expression.Catch(error, Expression.Call(engineExpr, nameof(EngineInstance.ReportError), Type.EmptyTypes, error))) - : (Expression)call; - var callback = Expression.Lambda(body, parameters); - return Expression.Lambda>(callback, objExpr, engineExpr).Compile(); + : (Expression)Expression.Convert( + Expression.Call(typeof(JsValueExtensions), nameof(JsValueExtensions.As), Type.EmptyTypes, call, Expression.Constant(methodInfo.ReturnType, typeof(Type)), engineExpr), + methodInfo.ReturnType); + var run = Expression.Block( + body.Type, + Expression.Call(engineExpr, nameof(EngineInstance.EnterScript), Type.EmptyTypes), + Expression.TryFinally(body, Expression.Call(engineExpr, nameof(EngineInstance.LeaveScript), Type.EmptyTypes))); + return Expression.Lambda(run, parameters); } } } diff --git a/src/AngleSharp.Js/Extensions/EngineExtensions.cs b/src/AngleSharp.Js/Extensions/EngineExtensions.cs index 5907a724..b03513cd 100644 --- a/src/AngleSharp.Js/Extensions/EngineExtensions.cs +++ b/src/AngleSharp.Js/Extensions/EngineExtensions.cs @@ -31,6 +31,10 @@ public static JsValue ToJsValue(this Object obj, EngineInstance engine) // what script threw, e.g. for the error of an ErrorEvent return thrown.Error; } + else if (obj is DomException exception) + { + return engine.GetDomException(exception); + } else if (engine.IsOutOfReach(obj)) { return JsValue.Null; @@ -74,6 +78,10 @@ public static JsValue ToJsValue(this Object obj, EngineInstance engine) { return JsNumber.Create(Convert.ToDouble(obj, System.Globalization.CultureInfo.InvariantCulture)); } + else if (obj is DateTime date) + { + return JsValue.FromObject(engine.Jint, date); + } else if (obj is Byte[] bytes) { // What WebIDL calls an ArrayBuffer; a copy as the object owning the array keeps it. @@ -204,7 +212,15 @@ public static Object[] BuildArgs(this EngineInstance context, MethodBase method, for (var i = max; i < arguments.Length; i++) { - array.SetValue(arguments[i].FromJsValue(), i - max); + // A WebIDL (Node or DOMString)... such as of append takes text as a text node. + var item = arguments[i].FromJsValue(); + + if (item is not null and not INode && array.GetType().GetElementType() == typeof(INode)) + { + item = ((IWindow)context.Window.Value).Document.CreateTextNode(TypeConverter.ToString(arguments[i])); + } + + array.SetValue(item, i - max); } args[max] = array; @@ -235,7 +251,8 @@ private static JsValue[] ExpandInitDict(JsValue[] arguments, ParameterDescriptio } else { - newArgs[i - offset] = JsValue.Undefined; + // what has no default isn't to become the text "undefined" or the like + newArgs[i - offset] = p.IsOptional ? JsValue.Undefined : JsValue.Null; } } @@ -392,7 +409,7 @@ private static PropertyDescriptor CreateConstructorProperty(EngineInstance engin PropertyDescriptor.CreateLazy( new ConstructorRequest(engine, definition), static request => request.Instance.GetDomConstructor(request.Definition), - PropertyFlag.OnlyEnumerable); + PropertyFlag.NonEnumerable); // Handed to the factory instead of captured by it, so that the delegate above is the same // one for every type rather than a closure allocated per registered name. @@ -571,7 +588,7 @@ public static JsValue CallStatic(this EngineInstance instance, MethodInfo method } catch (TargetInvocationException ex) { - throw new JavaScriptException(instance.Jint.Intrinsics.Error, ex.InnerException?.Message); + throw instance.ToScriptError(ex); } } @@ -591,6 +608,14 @@ public static JsValue Call(this EngineInstance instance, MethodInfo method, JsVa nodeInstance = instance.Window; } + // what a script did is complete before custom elements get to react to it + var holdsReactions = CustomElementRegistry.IsInUse; + + if (holdsReactions) + { + CustomElementRegistry.PushReactions(); + } + try { var description = MethodDescription.Of(method); @@ -611,16 +636,16 @@ public static JsValue Call(this EngineInstance instance, MethodInfo method, JsVa return description.ToGiven(bound.Invoke(target, parameters)).ToJsValue(instance, description.ReturnType); } } - catch (TargetInvocationException ex) when (ex.InnerException is JavaScriptException inner) + catch (TargetInvocationException ex) { - // a member throwing for script, e.g. a TypeError - throw inner; + throw instance.ToScriptError(ex); } - catch (TargetInvocationException ex) + finally { - // 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); + if (holdsReactions) + { + CustomElementRegistry.PopReactions(); + } } } diff --git a/src/AngleSharp.Js/Extensions/JsValueExtensions.cs b/src/AngleSharp.Js/Extensions/JsValueExtensions.cs index f62850cd..870d0294 100644 --- a/src/AngleSharp.Js/Extensions/JsValueExtensions.cs +++ b/src/AngleSharp.Js/Extensions/JsValueExtensions.cs @@ -1,6 +1,7 @@ namespace AngleSharp.Js { using AngleSharp.Attributes; + using AngleSharp.Dom; using AngleSharp.Js.Cache; using Jint; using Jint.Native; @@ -50,7 +51,7 @@ public static Type GetSequenceElementType(this Type type) // Copied rather than shared: the view may only cover a part of its buffer, and the CLR // side keeps what it was given while the script is free to change the buffer afterwards. - private static Byte[] GetBytes(ObjectInstance source) + internal static Byte[] GetBytes(this ObjectInstance source) { if (source is JsArrayBuffer buffer) { @@ -162,6 +163,11 @@ private static Object AsComplex(this JsValue value, Type targetType, EngineInsta f = new ClrFunction(e, "AsComplex", (_this, args) => e.Intrinsics.Eval.Call(_this, p)); } + if (f == null && obj != null && value is ObjectInstance callbackObject) + { + f = callbackObject.AsCallbackObject(targetType, engine); + } + if (f != null) { return targetType.ToDelegate(f, engine); @@ -194,7 +200,7 @@ private static Object AsComplex(this JsValue value, Type targetType, EngineInsta } var method = sourceType.PrepareConvert(targetType) ?? - throw new JavaScriptException("[Internal] Could not find corresponding cast target."); + throw new JavaScriptException($"[Internal] Could not find corresponding cast target from {sourceType.Name} to {targetType.Name}."); return method.IsStatic ? method.Invoke(null, new[] { obj }) : method.Invoke(obj, null); } diff --git a/src/AngleSharp.Js/Extensions/ReflectionExtensions.cs b/src/AngleSharp.Js/Extensions/ReflectionExtensions.cs index b598fb59..3793263a 100644 --- a/src/AngleSharp.Js/Extensions/ReflectionExtensions.cs +++ b/src/AngleSharp.Js/Extensions/ReflectionExtensions.cs @@ -23,6 +23,19 @@ public static IEnumerable GetExtensionTypes(this IEnumerable lib .Where(m => m.GetTypeInfo().GetCustomAttributes().Any(n => n.Target.Is(name))) .ToArray(); + public static Type GetGenericInterface(this Type type, Type definition) + { + foreach (var candidate in type.GetTypeInfo().ImplementedInterfaces) + { + if (candidate.GetTypeInfo().IsGenericType && candidate.GetGenericTypeDefinition() == definition) + { + return candidate; + } + } + + return null; + } + public static IEnumerable GetTypeTree(this Type root) { var type = root; diff --git a/src/AngleSharp.Js/JsConfigurationExtensions.cs b/src/AngleSharp.Js/JsConfigurationExtensions.cs index 7fd7e71b..98e80bc8 100644 --- a/src/AngleSharp.Js/JsConfigurationExtensions.cs +++ b/src/AngleSharp.Js/JsConfigurationExtensions.cs @@ -2,6 +2,7 @@ namespace AngleSharp { using AngleSharp.Browser; using AngleSharp.Browser.Dom; + using AngleSharp.Dom; using AngleSharp.Js; using AngleSharp.Js.Dom; using AngleSharp.Scripting; @@ -81,7 +82,9 @@ public static IConfiguration WithJs(this IConfiguration configuration, JsScripti return configuration .WithOnly(observer) .With(handler) - .With(service); + .With(service) + .With(_ => new ScriptValueCloner()) + .With(_ => new CustomElementHost(service)); } } } diff --git a/src/AngleSharp.Js/JsHost.cs b/src/AngleSharp.Js/JsHost.cs new file mode 100644 index 00000000..d03c4c52 --- /dev/null +++ b/src/AngleSharp.Js/JsHost.cs @@ -0,0 +1,51 @@ +namespace AngleSharp.Js +{ + using AngleSharp.Dom; + using AngleSharp.Io; + using AngleSharp.Text; + using Jint.Native; + using Jint.Runtime; + using Jint.Runtime.Modules; + using System; + using System.Collections.Generic; + + /// + /// Tells a module what the engine leaves to the host: where it comes from (import.meta.url). + /// + sealed class JsHost : Host + { + /// + /// Marks the module of a script written into a document, as it has to be told apart from + /// the others in there, while its address is the one of the document. + /// + public const String InlineModuleMark = "#inline-module-"; + + private readonly IDocument _document; + + public JsHost(IDocument document) + { + _document = document; + } + + public override List> GetImportMetaProperties(Module module) + { + var properties = base.GetImportMetaProperties(module) ?? new List>(); + properties.Add(new KeyValuePair("url", GetAddress(module.Location))); + return properties; + } + + private String GetAddress(String location) + { + var mark = location?.IndexOf(InlineModuleMark, StringComparison.Ordinal) ?? -1; + + if (mark >= 0) + { + return location.Substring(0, mark); + } + + return location != null && Uri.TryCreate(location, UriKind.Absolute, out var uri) && uri.Scheme.IsOneOf(ProtocolNames.Http, ProtocolNames.Https) + ? location + : _document.BaseUri; + } + } +} diff --git a/src/AngleSharp.Js/JsModuleLoader.cs b/src/AngleSharp.Js/JsModuleLoader.cs index ec547c4d..8e3440af 100644 --- a/src/AngleSharp.Js/JsModuleLoader.cs +++ b/src/AngleSharp.Js/JsModuleLoader.cs @@ -9,12 +9,13 @@ namespace AngleSharp.Js using System; using System.Linq; - internal class JsModuleLoader : DefaultModuleLoader + internal class JsModuleLoader : DefaultModuleLoader, IModuleLoader { private readonly EngineInstance _instance; private readonly IResourceLoader _resourceLoader; private readonly IElement _scriptElement; private readonly string _documentUrl; + private readonly IDocument _document; public JsModuleLoader(EngineInstance instance, IDocument document, bool restrictToBasePath = true) : base (document.Url, restrictToBasePath) { @@ -22,6 +23,7 @@ public JsModuleLoader(EngineInstance instance, IDocument document, bool restrict _resourceLoader = document.Context.GetService(); _scriptElement = document.CreateElement(TagNames.Script); _documentUrl = document.Url; + _document = document; } public override ResolvedSpecifier Resolve(string referencingModuleLocation, ModuleRequest moduleRequest) @@ -43,7 +45,7 @@ public override ResolvedSpecifier Resolve(string referencingModuleLocation, Modu return new ResolvedSpecifier( moduleRequest, - moduleRequest.Specifier, + scopeModuleUrl.AbsoluteUri, scopeModuleUrl, SpecifierType.RelativeOrAbsolute); } @@ -60,11 +62,17 @@ public override ResolvedSpecifier Resolve(string referencingModuleLocation, Modu return new ResolvedSpecifier( moduleRequest, - moduleRequest.Specifier, + moduleUrl.AbsoluteUri, moduleUrl, SpecifierType.RelativeOrAbsolute); } + // On the web a specifier is an address, found from the module asking for it + if (TryResolveAddress(referencingModuleLocation, moduleRequest.Specifier, out var address)) + { + return new ResolvedSpecifier(moduleRequest, address.AbsoluteUri, address, SpecifierType.RelativeOrAbsolute); + } + // Before passing to the base default module loader, make sure any relative paths are prepended with a dot // as Jint will otherwise resolve them as absolute file paths when running on Linux if (moduleRequest.Specifier.StartsWith("/")) @@ -80,6 +88,39 @@ public override ResolvedSpecifier Resolve(string referencingModuleLocation, Modu return base.Resolve(referencingModuleLocation, moduleRequest); } + private bool TryResolveAddress(string referencingModuleLocation, string specifier, out Uri address) + { + address = null; + var isRelative = specifier.StartsWith("/") || specifier.StartsWith("./") || specifier.StartsWith("../"); + + if (!isRelative && !IsWebAddress(specifier)) + { + return false; + } + + var from = IsWebAddress(referencingModuleLocation) ? referencingModuleLocation : _document.BaseUri; + return IsWebAddress(from) && Uri.TryCreate(new Uri(from), specifier, out address); + } + + private static bool IsWebAddress(string value) => + value != null && Uri.TryCreate(value, UriKind.Absolute, out var uri) && uri.Scheme.IsOneOf(ProtocolNames.Http, ProtocolNames.Https); + + Module IModuleLoader.LoadModule(Engine engine, ResolvedSpecifier resolved) + { + var request = resolved.ModuleRequest; + var isSource = !request.IsJsonModule() && !request.IsTextModule() && !request.IsBytesModule(); + + if (isSource && resolved.Uri?.Scheme.IsOneOf(ProtocolNames.Http, ProtocolNames.Https) == true) + { + // Jint names a module with an address by the path alone, while it is the address that + // errors and import.meta tell, and that other modules are found from + var named = new ResolvedSpecifier(request, resolved.Uri.AbsoluteUri, null, resolved.Type); + return ModuleFactory.BuildSourceTextModule(engine, named, FetchModule(resolved.Uri)); + } + + return base.LoadModule(engine, resolved); + } + protected override string LoadModuleContents(Engine engine, ResolvedSpecifier resolved) { if (resolved.Uri?.Scheme.IsOneOf(ProtocolNames.Http, ProtocolNames.Https) == true) @@ -103,7 +144,12 @@ private string FetchModule(Uri moduleUrl) var request = new ResourceRequest(_scriptElement, importUrl); - var response = _resourceLoader.FetchAsync(request).Task.Result; + var response = _resourceLoader.FetchAsync(request).Task.GetAwaiter().GetResult(); + + if (response == null) + { + throw new InvalidOperationException($"The module {moduleUrl} could not be loaded."); + } string content; diff --git a/src/AngleSharp.Js/JsScriptingService.cs b/src/AngleSharp.Js/JsScriptingService.cs index 0501203c..5e5bbcbd 100644 --- a/src/AngleSharp.Js/JsScriptingService.cs +++ b/src/AngleSharp.Js/JsScriptingService.cs @@ -102,8 +102,9 @@ public async Task EvaluateScriptAsync(IResponse response, ScriptOptions options, // A module is imported by this, so it has to tell it apart from other modules: // one written into the document would have the address of the document. var isModule = type.Isi("module"); - var sourceUrl = isModule ? options.Element?.Source : response.Address?.Href ?? options.Element?.Source; - var position = isModule || !String.IsNullOrEmpty(options.Element?.Source) ? default : FindInDocument(options.Document, content); + var isInline = String.IsNullOrEmpty(options.Element?.Source); + var sourceUrl = GetSourceUrl(response, options, isModule, isInline); + var position = isInline ? FindInDocument(options.Document, content) : default; await options.EventLoop.EnqueueAsync(_ => { var document = options.Document ?? throw new ArgumentNullException(nameof(options)); @@ -130,6 +131,23 @@ await options.EventLoop.EnqueueAsync(_ => } } + /// + /// The address a script tells in errors. A module is imported by this, too, so it has to tell + /// it apart from other modules: one written into the document gets its own, and the address + /// of something not on the web isn't one to find others from. + /// + private static String GetSourceUrl(IResponse response, ScriptOptions options, Boolean isModule, Boolean isInline) + { + var address = response.Address; + + if (!isModule) + { + return address?.Href ?? options.Element?.Source; + } + + return isInline ? null : address != null && address.Scheme.IsOneOf(ProtocolNames.Http, ProtocolNames.Https) ? address.Href : options.Element?.Source; + } + /// /// Finds a script written into the document, which is being parsed up to the end of it. The /// lines of a script are normalized by the parser, while the source of the document isn't. diff --git a/src/AngleSharp.Js/Proxies/DomConstructorInstance.cs b/src/AngleSharp.Js/Proxies/DomConstructorInstance.cs index 27cd1a3b..84152aaf 100644 --- a/src/AngleSharp.Js/Proxies/DomConstructorInstance.cs +++ b/src/AngleSharp.Js/Proxies/DomConstructorInstance.cs @@ -1,6 +1,8 @@ namespace AngleSharp.Js { + using AngleSharp.Dom; using AngleSharp.Js.Cache; + using Jint; using Jint.Native; using Jint.Native.Object; using Jint.Native.Symbol; @@ -92,6 +94,13 @@ private JsValue HasInstance(JsValue thisObject, JsValue[] arguments) { var value = arguments.Length > 0 ? arguments[0] : JsValue.Undefined; + // A class deriving from this one inherits this as a static member, while being of the + // type of a DOM object says nothing about being of that class. + if (!ReferenceEquals(thisObject, this)) + { + return IsInChainOf(value as ObjectInstance, thisObject.Get("prototype") as ObjectInstance) ? JsBoolean.True : JsBoolean.False; + } + if (value is IDomProxy node && IsInstance(node.DomType)) { return JsBoolean.True; @@ -125,6 +134,21 @@ private Boolean IsInstance(Type type) return false; } + private static Boolean IsInChainOf(ObjectInstance obj, ObjectInstance prototype) + { + while (obj != null && prototype != null) + { + obj = obj.Prototype; + + if (ReferenceEquals(obj, prototype)) + { + return true; + } + } + + return false; + } + private Boolean InheritsFromPrototype(ObjectInstance obj) { while (obj != null) @@ -142,9 +166,15 @@ private Boolean InheritsFromPrototype(ObjectInstance obj) public override ObjectInstance Construct(JsValue[] arguments, JsValue newTarget) { + // a class deriving from an element is about the element of that class + if (newTarget != null && !ReferenceEquals(newTarget, this) && !newTarget.IsUndefined() && typeof(IElement).IsAssignableFrom(_type)) + { + return _instance.ConstructCustomElement(newTarget); + } + if (_constructors.Length == 0) { - throw new JavaScriptException("Illegal constructor."); + throw new JavaScriptException(_instance.Jint.Intrinsics.TypeError, "Illegal constructor."); } try @@ -152,12 +182,19 @@ public override ObjectInstance Construct(JsValue[] arguments, JsValue newTarget) var constructor = _constructors.Length == 1 ? _constructors[0] : _constructors.SelectOverload(arguments); var parameters = _instance.BuildArgs(constructor, arguments); var obj = constructor.Invoke(parameters); - return _instance.GetDomNode(obj); + var node = _instance.GetDomNode(obj); + + // A class deriving from it has the instance on its prototype chain. + if (newTarget is ObjectInstance derived && !ReferenceEquals(derived, this) && derived.Get("prototype") is ObjectInstance prototype) + { + node.Prototype = prototype; + } + + return node; } catch (TargetInvocationException ex) { - // The message is all a script gets to see of what went wrong. - throw new JavaScriptException(_instance.Jint.Intrinsics.Error, ex.InnerException?.Message); + throw _instance.ToScriptError(ex); } catch { diff --git a/src/AngleSharp.Js/Proxies/DomEventDefinition.cs b/src/AngleSharp.Js/Proxies/DomEventDefinition.cs index f1d9c11e..aa6d35e6 100644 --- a/src/AngleSharp.Js/Proxies/DomEventDefinition.cs +++ b/src/AngleSharp.Js/Proxies/DomEventDefinition.cs @@ -1,11 +1,9 @@ namespace AngleSharp.Js { using AngleSharp.Dom; - using AngleSharp.Dom.Events; using Jint; using Jint.Native; using Jint.Native.Function; - using Jint.Runtime; using System; using System.Reflection; @@ -31,6 +29,9 @@ public DomEventDefinition(MethodInfo addHandler, MethodInfo removeHandler) _removeHandler = removeHandler; } + public Boolean IsSameAs(DomEventDefinition other) => + _addHandler == other._addHandler && _removeHandler == other._removeHandler; + public JsValue GetHandler(JsValue thisObject, JsValue[] arguments) { var node = thisObject.As(); @@ -57,28 +58,7 @@ public JsValue SetHandler(JsValue thisObject, JsValue[] arguments) if (value.AsFunction(engine) is Function function) { - var isWindow = node.Value is IWindow; - - DomEventHandler handler = (s, ev) => - { - var sender = s.ToJsValue(engine); - - try - { - if (isWindow && ev is ErrorEvent error) - { - CallErrorHandler(function, sender, error, engine); - } - else - { - function.Call(sender, new[] { ev.ToJsValue(engine) }); - } - } - catch (JavaScriptException thrown) - { - engine.ReportError(thrown); - } - }; + var handler = function.ToEventHandler(engine); node.SetEventHandler(this, new Registration(value, handler)); _addHandler?.Invoke(node.Value, new Object[] { handler }); @@ -88,27 +68,6 @@ public JsValue SetHandler(JsValue thisObject, JsValue[] arguments) return value; } - /// - /// The onerror of a window is the one handler not getting the event (HTML 8.1.8.1), and - /// returning true from it means that the error has been handled. - /// - private static void CallErrorHandler(Function function, JsValue sender, ErrorEvent error, EngineInstance engine) - { - var result = function.Call(sender, new[] - { - (error.Message ?? String.Empty).ToJsValue(engine), - (error.FileName ?? String.Empty).ToJsValue(engine), - error.Line.ToJsValue(engine), - error.Column.ToJsValue(engine), - error.Error.ToJsValue(engine), - }); - - if (result.IsBoolean() && result.AsBoolean()) - { - error.Cancel(); - } - } - /// /// The handler currently assigned to a single node for a single event. /// diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 2a5d1384..07184205 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -26,6 +26,6 @@ NuGet resolves the lowest match, so a build cannot quietly take a dependency on an API added in a later 1.x. --> - 1.8.0 + 1.10.0 From b9beab3871a7b0d32f5f26a3974fc854b16aa079 Mon Sep 17 00:00:00 2001 From: Florian Rappl Date: Thu, 5 Nov 2026 12:51:08 +0100 Subject: [PATCH 09/13] Improved module loading and open document --- CHANGELOG.md | 1 + src/AngleSharp.Js.Tests/DocumentOpenTests.cs | 116 ++++++++++++++++++ .../Mocks/MockFileRequester.cs | 39 ++++++ src/AngleSharp.Js.Tests/ModuleTests.cs | 97 +++++++++++++++ src/AngleSharp.Js/Dom/WindowExtensions.cs | 2 +- src/AngleSharp.Js/JsHost.cs | 18 ++- src/AngleSharp.Js/JsModuleLoader.cs | 2 +- src/AngleSharp.Js/JsScriptingService.cs | 21 +--- 8 files changed, 270 insertions(+), 26 deletions(-) create mode 100644 src/AngleSharp.Js.Tests/DocumentOpenTests.cs create mode 100644 src/AngleSharp.Js.Tests/Mocks/MockFileRequester.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b0f717a..2bbcdffe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ Released on Friday, November 6 2026. +- Fixed `window.origin` being `null` for a frame without an address of its own instead of the origin of its creator - Fixed text given to `append`, `prepend` and the like failing instead of becoming a text node - Fixed a class deriving from a type of the DOM not having its instances on its prototype chain - Fixed an object with `handleEvent` not being accepted as an event listener, or one with `acceptNode` as a filter of a `TreeWalker` or `NodeIterator` diff --git a/src/AngleSharp.Js.Tests/DocumentOpenTests.cs b/src/AngleSharp.Js.Tests/DocumentOpenTests.cs new file mode 100644 index 00000000..7945d812 --- /dev/null +++ b/src/AngleSharp.Js.Tests/DocumentOpenTests.cs @@ -0,0 +1,116 @@ +namespace AngleSharp.Js.Tests +{ + using AngleSharp.Browser; + using AngleSharp.Dom; + using AngleSharp.Html.Dom; + using NUnit.Framework; + using System; + using System.Threading.Tasks; + + /// + /// A handler is called on the event loop, which document.open must not wait for. + /// + [TestFixture] + public class DocumentOpenTests + { + private static async Task LoadAsync(String html) + { + var config = Configuration.Default.WithJs().WithEventLoop(); + return await BrowsingContext.New(config).OpenAsync(m => m.Content(html)); + } + + private static async Task ClickAsync(IDocument document, String id) + { + var done = new TaskCompletionSource(); + var loop = ((AngleSharp.Dom.Document)document).Loop; + loop.Enqueue(() => ((IHtmlElement)document.GetElementById(id)).DoClick()); + // after what the click may have queued itself + loop.Enqueue(() => loop.Enqueue(() => done.TrySetResult(true))); + var finished = await Task.WhenAny(done.Task, Task.Delay(10000)); + Assert.AreSame(done.Task, finished, "The event loop is stuck."); + } + + [Test] + public async Task HandlerOpensWritesAndClosesTheDocument() + { + var document = await LoadAsync(@"

"); + + await ClickAsync(document, "b"); + + Assert.IsNull(document.GetElementById("old")); + Assert.AreEqual("x", document.GetElementById("new").TextContent); + Assert.AreEqual(DocumentReadyState.Complete, document.ReadyState); + } + + [Test] + public async Task EmptyFrameCanBeWrittenToFromAScript() + { + var document = await LoadAsync(@""); + await document.WhenStable(); + var frame = (IHtmlInlineFrameElement)document.QuerySelector("iframe"); + + await frame.ContentDocument.WhenStable(); + + Assert.AreEqual("trueabout:blankcompletehttp://localhosthttp://localhost", document.Title); + Assert.AreEqual("written", frame.ContentDocument.GetElementById("x").TextContent); + Assert.AreEqual("true", frame.ContentDocument.Body.GetAttribute("ran")); + } + + [Test] + public async Task EmptyFrameFiresLoadWhenInserted() + { + var document = await LoadAsync(@""); + await document.WhenStable(); + var done = new TaskCompletionSource(); + var loop = ((AngleSharp.Dom.Document)document).Loop; + loop.Enqueue(() => done.TrySetResult(true)); + await done.Task; + + Assert.AreEqual("loadedtrue", document.Title); + } + + [TestCase("https://page.test/", "https://page.test")] + public async Task OriginOfFramesWithoutAnAddressIsTheOneOfTheirCreator(String address, String origin) + { + var config = Configuration.Default.WithJs().WithEventLoop(); + var document = await BrowsingContext.New(config).OpenAsync(m => m.Address(address).Content(@"")); + await document.WhenStable(); + + for (var i = 0; i < 300 && (((IHtmlInlineFrameElement)document.GetElementById("s")).ContentDocument is null || ((IHtmlInlineFrameElement)document.GetElementById("b")).ContentDocument is null); i++) + { + await Task.Delay(10); + } + + Assert.AreEqual(origin, document.ExecuteScript("window.origin")); + Assert.AreEqual(origin, document.ExecuteScript("document.getElementById('e').contentWindow.origin")); + Assert.AreEqual(origin, document.ExecuteScript("document.getElementById('s').contentWindow.origin")); + Assert.AreEqual("null", ((IHtmlInlineFrameElement)document.GetElementById("b")).ContentDocument.ExecuteScript("window.origin")); + } + + [Test] + public async Task WriteWithoutCloseIsParsedWhenTheHandlerIsDone() + { + var document = await LoadAsync(@"