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
141 changes: 141 additions & 0 deletions src/AngleSharp.Js.Tests/DeferredConstructorTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
namespace AngleSharp.Js.Tests
{
using NUnit.Framework;
using System.Threading.Tasks;

/// <summary>
/// The constructor of an exposed type is only built once script reads the property it
/// is published under, so these cover what a reader is entitled to see either way.
/// </summary>
[TestFixture]
public class DeferredConstructorTests
{
[Test]
public async Task ConstructorIsSameObjectOnWindowAndGlobal()
{
var result = await "String(window.HTMLDivElement === HTMLDivElement)".EvalScriptAsync();
Assert.AreEqual("true", result);
}

[Test]
public async Task ConstructorIsSameObjectOnEveryRead()
{
var result = await "String((function () { var a = HTMLDivElement; var b = window.HTMLDivElement; return a === b && a === HTMLDivElement; })())".EvalScriptAsync();
Assert.AreEqual("true", result);
}

[Test]
public async Task ConstructorIsFunction()
{
var result = await "typeof HTMLDivElement".EvalScriptAsync();
Assert.AreEqual("function", result);
}

[Test]
public async Task UnreadConstructorIsStillOwnPropertyOfWindow()
{
var result = await "String(window.hasOwnProperty('HTMLTableColElement'))".EvalScriptAsync();
Assert.AreEqual("true", result);
}

[Test]
public async Task UnreadConstructorIsStillEnumerable()
{
var result = await "String(Object.keys(window).indexOf('HTMLTableColElement') !== -1)".EvalScriptAsync();
Assert.AreEqual("true", result);
}

[Test]
public async Task UnreadConstructorIsStillFoundByForIn()
{
var result = await "String((function () { for (var k in window) { if (k === 'HTMLTableColElement') { return true; } } return false; })())".EvalScriptAsync();
Assert.AreEqual("true", 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);
}

[Test]
public async Task DescriptorValueIsTheConstructor()
{
var result = await "String(Object.getOwnPropertyDescriptor(window, 'HTMLDivElement').value === HTMLDivElement)".EvalScriptAsync();
Assert.AreEqual("true", result);
}

[Test]
public async Task ReadingAConstructorDoesNotChangeTheKeysOfWindow()
{
var result = await "String((function () { var before = Object.keys(window).length; var c = HTMLDivElement; return before === Object.keys(window).length; })())".EvalScriptAsync();
Assert.AreEqual("true", result);
}

[Test]
public async Task ConstructedInstanceIsInstanceOfItsConstructor()
{
var result = await "String(new CustomEvent('foo') instanceof CustomEvent)".EvalScriptAsync();
Assert.AreEqual("true", result);
}

[Test]
public async Task ConstructorBuildsInstances()
{
var result = await "new CustomEvent('foo').type".EvalScriptAsync();
Assert.AreEqual("foo", result);
}

[Test]
public async Task PrototypePointsBackAtItsConstructor()
{
var result = await "String(HTMLDivElement.prototype.constructor === HTMLDivElement)".EvalScriptAsync();
Assert.AreEqual("true", result);
}

// Reaching a prototype through an instance is the one path that never names the
// type, so it is the one that has to pull the constructor in by itself.
[Test]
public async Task InstanceReportsItsConstructorWhenTheNameWasNeverRead()
{
var result = await "screen.constructor.name".EvalScriptAsync();
Assert.AreEqual("Screen", result);
}

[Test]
public async Task InstanceReportsTheSameConstructorTheWindowPublishes()
{
var result = await "String(screen.constructor === Screen)".EvalScriptAsync();
Assert.AreEqual("true", result);
}

[Test]
public async Task ConstructorIsNotWritable()
{
var result = await "String((function () { var before = HTMLDivElement; window.HTMLDivElement = 5; return window.HTMLDivElement === before; })())".EvalScriptAsync();
Assert.AreEqual("true", result);
}

[Test]
public async Task ConstructorIsNotConfigurable()
{
var result = await "String((delete window.HTMLDivElement) === false && typeof HTMLDivElement === 'function')".EvalScriptAsync();
Assert.AreEqual("true", result);
}

[Test]
public async Task ConstructorStringifiesAsNativeCode()
{
var result = await "String(HTMLDivElement)".EvalScriptAsync();
Assert.AreEqual("function HTMLDivElement() { [native code] }", result);
}

[Test]
public async Task NonConstructableTypeStillRejectsNew()
{
var result = await "(function () { try { new Node(); return 'no throw'; } catch (e) { return 'threw'; } })()".EvalScriptAsync();
Assert.AreEqual("threw", result);
}
}
}
58 changes: 43 additions & 15 deletions src/AngleSharp.Js/Cache/CreatorCache.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,16 @@ namespace AngleSharp.Js.Cache
{
static class CreatorCache
{
private static readonly ConcurrentDictionary<Type, Action<EngineInstance, ObjectInstance>> _constructorActions = new();

public static Action<EngineInstance, ObjectInstance> GetConstructorAction(this Type type)
private static readonly ConcurrentDictionary<Type, ConstructorDefinition> _constructorDefinitions = new();

/// <summary>
/// Gets what is needed to build the constructor object for a type, or null if the
/// type is not exposed as one. The answer depends on the type alone, so the null
/// is cached as well - most exported types do not get a constructor.
/// </summary>
public static ConstructorDefinition GetConstructorDefinition(this Type type)
{
if (!_constructorActions.TryGetValue(type, out var action))
if (!_constructorDefinitions.TryGetValue(type, out var definition))
{
var ti = type.GetTypeInfo();
var names = ti.GetCustomAttributes<DomNameAttribute>();
Expand All @@ -25,21 +30,13 @@ public static Action<EngineInstance, ObjectInstance> GetConstructorAction(this T
if (name != null && !ti.IsEnum)
{
var info = ti.DeclaredConstructors.FirstOrDefault(m => m.GetCustomAttributes<DomConstructorAttribute>().Any());
action = (engine, obj) =>
{
var constructor = info != null ? new DomConstructorInstance(engine, info) : new DomConstructorInstance(engine, type);
obj.FastSetProperty(name.OfficialName, new PropertyDescriptor(constructor, false, true, false));
};
}
else
{
action = (e, o) => { };
definition = new ConstructorDefinition(type, name.OfficialName, info);
}

_constructorActions.TryAdd(type, action);
_constructorDefinitions.TryAdd(type, definition);
}

return action;
return definition;
}

private static readonly ConcurrentDictionary<Type, Action<EngineInstance, ObjectInstance>> _constructorFunctionActions = new();
Expand Down Expand Up @@ -111,4 +108,35 @@ public static Action<EngineInstance, ObjectInstance> GetInstanceAction(this Type
return action;
}
}

/// <summary>
/// Everything the constructor object of a type is built from. The reflection behind it
/// is the same for every engine, so it is resolved once and kept by
/// <see cref="CreatorCache"/> - only the object built from it belongs to an engine.
/// </summary>
sealed class ConstructorDefinition
{
public ConstructorDefinition(Type type, String name, ConstructorInfo info)
{
Type = type;
Name = name;
Info = info;
}

/// <summary>
/// Gets the type the constructor creates instances of.
/// </summary>
public Type Type { get; }

/// <summary>
/// Gets the name the constructor is exposed under.
/// </summary>
public String Name { get; }

/// <summary>
/// Gets the constructor to invoke, or null if the type cannot be constructed from
/// script - naming it is still legal, calling it is not.
/// </summary>
public ConstructorInfo Info { get; }
}
}
13 changes: 13 additions & 0 deletions src/AngleSharp.Js/EngineInstance.cs
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,19 @@ public EngineInstance(IWindow window, IDictionary<String, Object> assignments, I

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

/// <summary>
/// Gets the constructor object of the given type, building it on first ask. The
/// prototype keeps it, so that naming the type and reading "constructor" off one of
/// its instances arrive at the same object.
/// </summary>
public DomConstructorInstance GetDomConstructor(ConstructorDefinition definition)
{
// Only the prototype of System.Object is not one of ours, and that type is not
// exposed as a constructor, so it never reaches this point.
var prototype = (DomPrototypeInstance)GetDomPrototype(definition.Type);
return prototype.GetConstructor(definition);
}

public JsValue RunScript(String source, String type, String sourceUrl)
{
if (string.IsNullOrEmpty(type))
Expand Down
8 changes: 6 additions & 2 deletions src/AngleSharp.Js/Extensions/EngineExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -193,8 +193,12 @@ public static void AddInstances(this EngineInstance engine, ObjectInstance obj,

public static void AddConstructor(this EngineInstance engine, ObjectInstance obj, Type type)
{
var apply = type.GetConstructorAction();
apply.Invoke(engine, obj);
var definition = type.GetConstructorDefinition();

if (definition != null)
{
obj.FastSetProperty(definition.Name, new DomConstructorDescriptor(engine, definition));
}
}

public static void AddConstructorFunction(this EngineInstance engine, ObjectInstance obj, Type type)
Expand Down
37 changes: 37 additions & 0 deletions src/AngleSharp.Js/Proxies/DomConstructorDescriptor.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
namespace AngleSharp.Js
{
using AngleSharp.Js.Cache;
using Jint.Native;
using Jint.Runtime.Descriptors;

/// <summary>
/// The property an exposed type is published under on the window and on the global
/// object. A document names a handful of the types an assembly exposes, but a property
/// is registered for every one of them, so the constructor object behind it is only
/// built once script reads the property.
/// </summary>
sealed class DomConstructorDescriptor : PropertyDescriptor
{
private readonly EngineInstance _instance;
private readonly ConstructorDefinition _definition;
private JsValue _resolved;

// The attributes an eagerly written constructor had: enumerable, but neither
// writable nor configurable. CustomJsValue is what routes a read through
// CustomValue below; Jint reads that flag on every access instead of taking a
// copy of the value, so the descriptor keeps working once one of the engine's
// property caches has taken hold of it.
public DomConstructorDescriptor(EngineInstance instance, ConstructorDefinition definition)
: base(PropertyFlag.OnlyEnumerable | PropertyFlag.CustomJsValue)
{
_instance = instance;
_definition = definition;
}

protected override JsValue CustomValue
{
get => _resolved ?? (_resolved = _instance.GetDomConstructor(_definition));
set => _resolved = value;
}
}
}
15 changes: 5 additions & 10 deletions src/AngleSharp.Js/Proxies/DomConstructorInstance.cs
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
namespace AngleSharp.Js
{
using AngleSharp.Js.Cache;
using Jint.Native;
using Jint.Native.Object;
using Jint.Runtime;
using Jint.Runtime.Descriptors;
using Jint.Runtime.Interop;
using System;
using System.Reflection;

sealed class DomConstructorInstance : Constructor
Expand All @@ -14,12 +14,13 @@ sealed class DomConstructorInstance : Constructor
private readonly EngineInstance _instance;
private readonly ObjectInstance _objectPrototype;

public DomConstructorInstance(EngineInstance engine, Type type)
: base(engine.Jint, type.GetOfficialName())
public DomConstructorInstance(EngineInstance engine, ConstructorDefinition definition)
: base(engine.Jint, definition.Name)
{
var toString = new ClrFunction(Engine, "toString", ToString);
_objectPrototype = engine.GetDomPrototype(type);
_objectPrototype = engine.GetDomPrototype(definition.Type);
_instance = engine;
_constructor = definition.Info;
FastSetProperty("toString", new PropertyDescriptor(toString, true, false, true));
SetOwnProperty("prototype", new PropertyDescriptor(_objectPrototype, false, false, false));

Expand All @@ -37,12 +38,6 @@ public DomConstructorInstance(EngineInstance engine, Type type)
}
}

public DomConstructorInstance(EngineInstance engine, ConstructorInfo constructor)
: this(engine, constructor.DeclaringType)
{
_constructor = constructor;
}

public override ObjectInstance Construct(JsValue[] arguments, JsValue newTarget)
{
if (_constructor == null)
Expand Down
20 changes: 20 additions & 0 deletions src/AngleSharp.Js/Proxies/DomPrototypeInstance.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
namespace AngleSharp.Js
{
using AngleSharp.Attributes;
using AngleSharp.Js.Cache;
using AngleSharp.Text;
using Jint.Native.Object;
using Jint.Native.Symbol;
Expand All @@ -20,6 +21,7 @@ sealed class DomPrototypeInstance : ObjectInstance

private List<KeyValuePair<String, PropertyDescriptor>> _deferred;
private Boolean _membersSet;
private DomConstructorInstance _constructor;
private MethodInfo _numericIndexer;
private MethodInfo _stringIndexer;

Expand Down Expand Up @@ -58,8 +60,26 @@ protected override void Initialize()

_deferred = null;
}

// It is the constructor object that registers "constructor" here, and it is
// only built once script names the type. A prototype reached through an
// instance instead - the usual way - would otherwise lack the property.
var definition = _type.GetConstructorDefinition();

if (definition != null)
{
GetConstructor(definition);
}
}

/// <summary>
/// Gets the constructor object of the type this prototype belongs to, building it
/// on first ask. Holding it here is what keeps the one script reads off the window
/// and the one an instance reports as its "constructor" the same object.
/// </summary>
public DomConstructorInstance GetConstructor(ConstructorDefinition definition) =>
_constructor ?? (_constructor = new DomConstructorInstance(_instance, definition));

// The prototype link is only established once the members are known, so reading it
// has to initialize as well - not every reader goes through a property lookup.
protected override ObjectInstance GetPrototypeOf()
Expand Down
Loading