Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 143 additions & 0 deletions src/AngleSharp.Js.Tests/DomTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -128,5 +128,148 @@ public async Task AssignedPropertyIsReportedConsistently()
var result = await "(function () { var d = document.createElement('div'); d.custom = 1; return d.hasOwnProperty('custom') + ',' + Object.getOwnPropertyNames(d).join(); })()".EvalScriptAsync();
Assert.AreEqual("true,custom", result);
}

// Reading an index, testing it for existence and enumerating it are three different
// questions the engine asks, and the node answers each of them from a different
// method. They have to agree.
[Test]
public async Task IndexedEntryIsReportedAsAnOwnPropertyAndReads()
{
var result = await "(function () { var c = document.getElementsByTagName('script'); return c.hasOwnProperty(0) + ',' + (0 in c) + ',' + c[0].nodeName; })()".EvalScriptAsync();
Assert.AreEqual("true,true,SCRIPT", result);
}

[Test]
public async Task IndexBeyondTheEndIsNoOwnPropertyAndReadsUndefined()
{
var result = await "(function () { var c = document.getElementsByTagName('script'); return c.hasOwnProperty(5) + ',' + (5 in c) + ',' + (typeof c[5]); })()".EvalScriptAsync();
Assert.AreEqual("false,false,undefined", result);
}

// A member of an indexed collection must not be mistaken for an index. "length" is
// the collection's own property rather than an inherited one, which is what the
// array-like projection reports for it - a browser has it on the prototype instead.
[Test]
public async Task MemberOfAnIndexedCollectionIsNotMistakenForAnIndex()
{
var result = await "(function () { var c = document.getElementsByTagName('script'); return ('length' in c) + ',' + c.length + ',' + c.propertyIsEnumerable('length'); })()".EvalScriptAsync();
Assert.AreEqual("true,1,false", result);
}

// A DOM collection is array-like, not an array - which is exactly what a browser
// reports for one.
[Test]
public async Task CollectionIsNotAnArray()
{
var result = await "Array.isArray(document.getElementsByTagName('script'))".EvalScriptAsync();
Assert.AreEqual("False", result);
}

[Test]
public async Task CollectionKeepsItsDomIdentity()
{
var result = await "(function () { var c = document.getElementsByTagName('script'); return Object.prototype.toString.call(c) + ',' + (c instanceof HTMLCollection); })()".EvalScriptAsync();
Assert.AreEqual("[object HTMLCollection],true", result);
}

[Test]
public async Task CollectionCanBeIterated()
{
var result = await "(function () { var n = 0; for (var s of document.getElementsByTagName('script')) { n += s.nodeName.length; } return n; })()".EvalScriptAsync();
Assert.AreEqual("6", result);
}

[Test]
public async Task CollectionCanBeSpread()
{
var result = await "[...document.getElementsByTagName('script')].length".EvalScriptAsync();
Assert.AreEqual("1", result);
}

[Test]
public async Task ArrayGenericsRunOverACollection()
{
var result = await "Array.prototype.map.call(document.getElementsByTagName('script'), function (e) { return e.nodeName; }).join()".EvalScriptAsync();
Assert.AreEqual("SCRIPT", result);
}

[Test]
public async Task IndicesOfACollectionAreEnumerated()
{
var result = await "JSON.stringify(Object.keys(document.getElementsByTagName('script')))".EvalScriptAsync();
Assert.AreEqual("[\"0\"]", result);
}

// The platform-object shape: the projection owns its indices, so script cannot delete
// one or define over it.
[Test]
public async Task IndexOfACollectionCannotBeDeleted()
{
var result = await "(function () { var c = document.getElementsByTagName('script'); return delete c[0]; })()".EvalScriptAsync();
Assert.AreEqual("False", result);
}

[Test]
public async Task ExpandoOnACollectionIsStillPossible()
{
var result = await "(function () { var c = document.getElementsByTagName('script'); c.marker = 'kept'; return c.marker + ',' + c.hasOwnProperty('marker'); })()".EvalScriptAsync();
Assert.AreEqual("kept,true", result);
}

// Existence is answered from the collection's length alone, without producing the
// element - so it has to keep agreeing with what reading it would say.
[Test]
public async Task ExistenceOfAnIndexAgreesWithReadingIt()
{
var result = await "(function () { var c = document.getElementsByTagName('script'); var r = []; for (var i = 0; i < 3; i++) { r.push((i in c) + ':' + (c[i] !== undefined)); } return r.join(); })()".EvalScriptAsync();
Assert.AreEqual("true:true,false:false,false:false", result);
}

[Test]
public async Task NumericIndexerOfNamedNodeMapStillReadsBothWays()
{
var result = await "(function () { var d = document.createElement('div'); d.setAttribute('title', 't'); var a = d.attributes; return a.length + ',' + a[0].name + ',' + a.title.value; })()".EvalScriptAsync();
Assert.AreEqual("1,title,t", result);
}

// A symbol cannot be an index, and it must not be turned into one either - the
// well-known symbols are probed on every kind of object by library code.
[Test]
public async Task SymbolKeyOnAnIndexedCollectionIsNotTreatedAsAnIndex()
{
var result = await "(function () { var c = document.getElementsByTagName('script'); return c.hasOwnProperty(Symbol.toStringTag) + ',' + (typeof c[Symbol.toStringTag]); })()".EvalScriptAsync();
Assert.AreEqual("false,string", result);
}

// The node's own property set lives in the DOM, so nothing in the engine changes
// when an entry appears there. A name that was absent has to start resolving on the
// node from the very next read, rather than staying on whatever the prototype said.
[Test]
public async Task NamedEntryStartsResolvingOnTheNodeAsSoonAsItExists()
{
var result = await "(function () { var d = document.createElement('div'), a = d.attributes, before = typeof a.title; d.setAttribute('title', 't'); return before + ',' + a.title.value + ',' + a.hasOwnProperty('title'); })()".EvalScriptAsync();
Assert.AreEqual("undefined,t,true", result);
}

[Test]
public async Task AccessorDefinedOnANodeByScriptIsInvokedOnRead()
{
var result = await "(function () { var d = document.createElement('div'); Object.defineProperty(d, 'marker', { get: function () { return 'from getter'; } }); return d.marker + ',' + d.hasOwnProperty('marker'); })()".EvalScriptAsync();
Assert.AreEqual("from getter,true", result);
}

[Test]
public async Task NonEnumerablePropertyOfANodeIsSeenButNotEnumerated()
{
var result = await "(function () { var d = document.createElement('div'); Object.defineProperty(d, 'hidden2', { value: 1 }); return d.hasOwnProperty('hidden2') + ',' + d.propertyIsEnumerable('hidden2') + ',' + Object.keys(d).length; })()".EvalScriptAsync();
Assert.AreEqual("true,false,0", result);
}

[Test]
public async Task PropertyAssignedToANodeIsCopiedAndSerialized()
{
var result = await "(function () { var d = document.createElement('div'); d.custom = 'v'; return JSON.stringify(d) + ',' + JSON.stringify(Object.assign({}, d)); })()".EvalScriptAsync();
Assert.AreEqual("{\"custom\":\"v\"},{\"custom\":\"v\"}", result);
}
}
}
2 changes: 1 addition & 1 deletion src/AngleSharp.Js/AngleSharp.Js.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@

<ItemGroup>
<PackageReference Include="AngleSharp" Version="$(AngleSharpVersion)" />
<PackageReference Include="Jint" Version="4.14.0" />
<PackageReference Include="Jint" Version="4.15.0" />
</ItemGroup>

<PropertyGroup Condition=" '$(OS)' == 'Windows_NT' ">
Expand Down
177 changes: 177 additions & 0 deletions src/AngleSharp.Js/Cache/CollectionTypeCache.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
namespace AngleSharp.Js.Cache
{
using AngleSharp.Attributes;
using AngleSharp.Text;
using System;
using System.Collections.Concurrent;
using System.Linq;
using System.Linq.Expressions;
using System.Reflection;

/// <summary>
/// Decides whether a DOM type is an indexed collection - a node list, an HTML collection, a
/// token list, a named node map - and resolves the two members that make it one.
/// </summary>
/// <remarks>
/// This is the WebIDL shape: an indexed property getter plus a "length". Both are required,
/// because the engine derives the whole array-like property model from them and an object
/// offering only one of the two would be a collection whose end nothing can find. The answer
/// depends on the type alone, so the cache is process-wide - the reflection runs once per
/// type however many documents are opened.
/// </remarks>
static class CollectionTypeCache
{
private static readonly ConcurrentDictionary<Type, IndexedCollection> _collections = new();

public static IndexedCollection GetIndexedCollection(this Type type) =>
_collections.GetOrAdd(type, static current => Resolve(current));

private static IndexedCollection Resolve(Type type)
{
MethodInfo indexer = null;
MethodInfo length = null;

foreach (var current in type.GetTypeTree())
{
foreach (var property in current.GetTypeInfo().DeclaredProperties)
{
var getter = property.GetMethod;

if (getter == null)
{
continue;
}

var indexParameters = property.GetIndexParameters();

if (indexParameters.Length == 1)
{
// The same test SetIndexer applies, so a type is a collection here
// exactly when its prototype would have offered a numeric indexer.
if (indexer == null &&
indexParameters[0].ParameterType == typeof(Int32) &&
IsIndexedAccessor(property))
{
indexer = getter;
}
}
else if (length == null && indexParameters.Length == 0 && IsLength(property))
{
length = getter;
}
}
}

return indexer != null && length != null ? IndexedCollection.TryCreate(type, indexer, length) : null;
}

private static Boolean IsIndexedAccessor(PropertyInfo property)
{
var accessor = property.GetCustomAttribute<DomAccessorAttribute>()?.Type;

if (accessor.HasValue && (accessor.Value & (Accessors.Getter | Accessors.Setter)) != 0)
{
return true;
}

return property.GetCustomAttributes<DomNameAttribute>().Any(m => m.OfficialName.Is("item"));
}

private static Boolean IsLength(PropertyInfo property) =>
property.PropertyType == typeof(Int32) &&
property.GetCustomAttributes<DomNameAttribute>().Any(m => m.OfficialName.Is("length"));
}

/// <summary>
/// The indexed getter and the length of a collection type, compiled once.
/// </summary>
/// <remarks>
/// These two run on every single indexed read, which is the one place in this binding where
/// reflection is genuinely too slow: the engine reads an element without materialising a
/// descriptor or a key, so a MethodInfo.Invoke and the object[] it needs would be all that is
/// left of the cost. They are compiled instead, against the interface that declares them, so
/// the call is a virtual dispatch that lands on whatever concrete class the target happens to
/// be - the case a reflective invoke of an explicitly re-implemented member gets wrong.
/// </remarks>
sealed class IndexedCollection
{
private readonly Func<Object, Int32> _length;
private readonly Func<Object, Int32, Object> _item;

private IndexedCollection(Func<Object, Int32> length, Func<Object, Int32, Object> item)
{
_length = length;
_item = item;
}

public static IndexedCollection TryCreate(Type type, MethodInfo indexer, MethodInfo length)
{
// What carries the DOM attributes and what can actually be called are not always the
// same member: an interface may re-implement one of its own bases explicitly, as
// IHtmlCollection<T> does with "T IReadOnlyList<T>.this[Int32]", and that member is
// private and abstract. The declaration is what identifies the collection, the
// public method of the same name on the concrete class is what runs.
var resolvedIndexer = ResolvePublic(type, indexer);
var resolvedLength = ResolvePublic(type, length);

if (resolvedIndexer == null || resolvedLength == null)
{
return null;
}

try
{
return new IndexedCollection(
CompileLength(type, resolvedLength),
CompileItem(type, resolvedIndexer));
}
catch (Exception)
{
// A member that cannot be expressed as an expression tree leaves the type on the
// ordinary node proxy, which reaches the same accessors reflectively.
return null;
}
}

private static MethodInfo ResolvePublic(Type type, MethodInfo declared)
{
if (declared.IsPublic && declared.DeclaringType.GetTypeInfo().IsAssignableFrom(type.GetTypeInfo()))
{
return declared;
}

var name = declared.Name;
var simpleName = name.Substring(name.LastIndexOf('.') + 1);
var parameters = declared.GetParameters();
var parameterTypes = new Type[parameters.Length];

for (var i = 0; i < parameters.Length; i++)
{
parameterTypes[i] = parameters[i].ParameterType;
}

var resolved = type.GetRuntimeMethod(simpleName, parameterTypes);
return resolved != null && resolved.IsPublic ? resolved : null;
}

public Int32 GetLength(Object target) => _length.Invoke(target);

public Object GetItem(Object target, Int32 index) => _item.Invoke(target, index);

private static Func<Object, Int32> CompileLength(Type type, MethodInfo length)
{
var target = Expression.Parameter(typeof(Object), "target");
var call = Expression.Call(Expression.Convert(target, type), length);
return Expression.Lambda<Func<Object, Int32>>(call, target).Compile();
}

private static Func<Object, Int32, Object> CompileItem(Type type, MethodInfo indexer)
{
var target = Expression.Parameter(typeof(Object), "target");
var index = Expression.Parameter(typeof(Int32), "index");
var call = Expression.Call(Expression.Convert(target, type), indexer, index);
return Expression.Lambda<Func<Object, Int32, Object>>(
Expression.Convert(call, typeof(Object)), target, index).Compile();
}
}
}
7 changes: 4 additions & 3 deletions src/AngleSharp.Js/Cache/ReferenceCache.cs
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
namespace AngleSharp.Js
{
using Jint.Native.Object;
using System;
using System.Runtime.CompilerServices;

sealed class ReferenceCache
{
private readonly ConditionalWeakTable<Object, DomNodeInstance> _references;
private readonly ConditionalWeakTable<Object, ObjectInstance> _references;

public ReferenceCache()
{
_references = new ConditionalWeakTable<Object, DomNodeInstance>();
_references = new ConditionalWeakTable<Object, ObjectInstance>();
}

public DomNodeInstance GetOrCreate(Object obj, Func<Object, DomNodeInstance> creator) =>
public ObjectInstance GetOrCreate(Object obj, Func<Object, ObjectInstance> creator) =>
_references.GetValue(obj, creator.Invoke);
}
}
19 changes: 16 additions & 3 deletions src/AngleSharp.Js/EngineInstance.cs
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ public EngineInstance(IWindow window, IDictionary<String, Object> assignments, I
_engine.SetValue(assignment.Key, assignment.Value);
}

_window = GetDomNode(window);
_window = (DomNodeInstance)GetDomNode(window);

foreach (var lib in libs)
{
Expand Down Expand Up @@ -96,7 +96,7 @@ public EngineInstance(IWindow window, IDictionary<String, Object> assignments, I

#region Methods

public DomNodeInstance GetDomNode(Object obj) => _references.GetOrCreate(obj, CreateInstance);
public ObjectInstance GetDomNode(Object obj) => _references.GetOrCreate(obj, CreateInstance);

public ObjectInstance GetDomPrototype(Type type) => _prototypes.GetOrCreate(type, CreatePrototype);

Expand Down Expand Up @@ -215,7 +215,20 @@ private JsValue ImportModule(String specifier, String source)

#region Helpers

private DomNodeInstance CreateInstance(Object obj) => new DomNodeInstance(this, obj);
// 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)
{
var collection = obj.GetType().GetIndexedCollection();

if (collection != null)
{
return new DomCollectionInstance(this, obj, collection);
}

return new DomNodeInstance(this, obj);
}

private ObjectInstance CreatePrototype(Type type) => new DomPrototypeInstance(this, type);

Expand Down
Loading
Loading