Skip to content

Commit bd5e8cf

Browse files
committed
Merge branch 'devel' of https://github.com/AngleSharp/AngleSharp.Js into devel
2 parents b147133 + fc95022 commit bd5e8cf

26 files changed

Lines changed: 2421 additions & 585 deletions

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ Released on Friday, July 31 2026.
1414
- Updated to use AngleSharp v1
1515
- Updated for Jint v4 (#89, #97) @tomvanenckevort @lahma
1616
- Updated CreatorCache to be thread-safe (#110) @badnickname
17+
- Updated DOM prototypes to be built from a member layout shared by the whole process, which resolves a type once instead of once per document and lets the engine cache warm member reads @lahma
1718
- Added report for uncaught event-loop exceptions through the browsing context
1819
- Added `JsScriptingOptions` to tune the engine via `WithJs` (#75) @lahma
1920
- Added support for Web Workers (#78)

‎src/AngleSharp.Js.Tests/DomTests.cs‎

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,5 +128,169 @@ public async Task AssignedPropertyIsReportedConsistently()
128128
var result = await "(function () { var d = document.createElement('div'); d.custom = 1; return d.hasOwnProperty('custom') + ',' + Object.getOwnPropertyNames(d).join(); })()".EvalScriptAsync();
129129
Assert.AreEqual("true,custom", result);
130130
}
131+
132+
// Reading an index, testing it for existence and enumerating it are three different
133+
// questions the engine asks, and the node answers each of them from a different
134+
// method. They have to agree.
135+
[Test]
136+
public async Task IndexedEntryIsReportedAsAnOwnPropertyAndReads()
137+
{
138+
var result = await "(function () { var c = document.getElementsByTagName('script'); return c.hasOwnProperty(0) + ',' + (0 in c) + ',' + c[0].nodeName; })()".EvalScriptAsync();
139+
Assert.AreEqual("true,true,SCRIPT", result);
140+
}
141+
142+
[Test]
143+
public async Task IndexBeyondTheEndIsNoOwnPropertyAndReadsUndefined()
144+
{
145+
var result = await "(function () { var c = document.getElementsByTagName('script'); return c.hasOwnProperty(5) + ',' + (5 in c) + ',' + (typeof c[5]); })()".EvalScriptAsync();
146+
Assert.AreEqual("false,false,undefined", result);
147+
}
148+
149+
// A member of an indexed collection must not be mistaken for an index. "length" is
150+
// the collection's own property rather than an inherited one, which is what the
151+
// array-like projection reports for it - a browser has it on the prototype instead.
152+
[Test]
153+
public async Task MemberOfAnIndexedCollectionIsNotMistakenForAnIndex()
154+
{
155+
var result = await "(function () { var c = document.getElementsByTagName('script'); return ('length' in c) + ',' + c.length + ',' + c.propertyIsEnumerable('length'); })()".EvalScriptAsync();
156+
Assert.AreEqual("true,1,false", result);
157+
}
158+
159+
// A DOM collection is array-like, not an array - which is exactly what a browser
160+
// reports for one.
161+
[Test]
162+
public async Task CollectionIsNotAnArray()
163+
{
164+
var result = await "Array.isArray(document.getElementsByTagName('script'))".EvalScriptAsync();
165+
Assert.AreEqual("False", result);
166+
}
167+
168+
[Test]
169+
public async Task CollectionKeepsItsDomIdentity()
170+
{
171+
var result = await "(function () { var c = document.getElementsByTagName('script'); return Object.prototype.toString.call(c) + ',' + (c instanceof HTMLCollection); })()".EvalScriptAsync();
172+
Assert.AreEqual("[object HTMLCollection],true", result);
173+
}
174+
175+
[Test]
176+
public async Task CollectionCanBeIterated()
177+
{
178+
var result = await "(function () { var n = 0; for (var s of document.getElementsByTagName('script')) { n += s.nodeName.length; } return n; })()".EvalScriptAsync();
179+
Assert.AreEqual("6", result);
180+
}
181+
182+
[Test]
183+
public async Task CollectionCanBeSpread()
184+
{
185+
var result = await "[...document.getElementsByTagName('script')].length".EvalScriptAsync();
186+
Assert.AreEqual("1", result);
187+
}
188+
189+
[Test]
190+
public async Task ArrayGenericsRunOverACollection()
191+
{
192+
var result = await "Array.prototype.map.call(document.getElementsByTagName('script'), function (e) { return e.nodeName; }).join()".EvalScriptAsync();
193+
Assert.AreEqual("SCRIPT", result);
194+
}
195+
196+
[Test]
197+
public async Task IndicesOfACollectionAreEnumerated()
198+
{
199+
var result = await "JSON.stringify(Object.keys(document.getElementsByTagName('script')))".EvalScriptAsync();
200+
Assert.AreEqual("[\"0\"]", result);
201+
}
202+
203+
// The platform-object shape: the projection owns its indices, so script cannot delete
204+
// one or define over it.
205+
[Test]
206+
public async Task IndexOfACollectionCannotBeDeleted()
207+
{
208+
var result = await "(function () { var c = document.getElementsByTagName('script'); return delete c[0]; })()".EvalScriptAsync();
209+
Assert.AreEqual("False", result);
210+
}
211+
212+
[Test]
213+
public async Task ExpandoOnACollectionIsStillPossible()
214+
{
215+
var result = await "(function () { var c = document.getElementsByTagName('script'); c.marker = 'kept'; return c.marker + ',' + c.hasOwnProperty('marker'); })()".EvalScriptAsync();
216+
Assert.AreEqual("kept,true", result);
217+
}
218+
219+
// Existence is answered from the collection's length alone, without producing the
220+
// element - so it has to keep agreeing with what reading it would say.
221+
[Test]
222+
public async Task ExistenceOfAnIndexAgreesWithReadingIt()
223+
{
224+
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();
225+
Assert.AreEqual("true:true,false:false,false:false", result);
226+
}
227+
228+
[Test]
229+
public async Task NumericIndexerOfNamedNodeMapStillReadsBothWays()
230+
{
231+
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();
232+
Assert.AreEqual("1,title,t", result);
233+
}
234+
235+
// An element whose id happens to be numeric must not surface as an index of the
236+
// collection. The array-like projection owns every array-index key - an index past the
237+
// end is authoritatively absent - so the named entry answers only non-index names,
238+
// which is also how WebIDL resolves the collision. Every answer has to say the same
239+
// thing, the descriptor included.
240+
[Test]
241+
public async Task NamedEntryWithAnIndexShapedNameIsNotAnIndex()
242+
{
243+
var result = await "(function () { var f = document.createElement('form'); f.id = '5'; document.documentElement.appendChild(f); var c = document.forms; return (Object.getOwnPropertyDescriptor(c, '5') === undefined) + ',' + ('5' in c) + ',' + (c['5'] === undefined) + ',' + c.hasOwnProperty('5') + ',' + (c[0] === f); })()".EvalScriptAsync();
244+
Assert.AreEqual("true,false,true,false,true", result);
245+
}
246+
247+
// The guard above must not overreach: a named entry whose name is not an array index
248+
// keeps resolving, descriptor and all.
249+
[Test]
250+
public async Task NamedEntryWithAnOrdinaryNameStillResolves()
251+
{
252+
var result = await "(function () { var f = document.createElement('form'); f.id = 'login'; document.documentElement.appendChild(f); var c = document.forms; return (c.login === f) + ',' + (Object.getOwnPropertyDescriptor(c, 'login') !== undefined); })()".EvalScriptAsync();
253+
Assert.AreEqual("true,true", result);
254+
}
255+
256+
// A symbol cannot be an index, and it must not be turned into one either - the
257+
// well-known symbols are probed on every kind of object by library code.
258+
[Test]
259+
public async Task SymbolKeyOnAnIndexedCollectionIsNotTreatedAsAnIndex()
260+
{
261+
var result = await "(function () { var c = document.getElementsByTagName('script'); return c.hasOwnProperty(Symbol.toStringTag) + ',' + (typeof c[Symbol.toStringTag]); })()".EvalScriptAsync();
262+
Assert.AreEqual("false,string", result);
263+
}
264+
265+
// The node's own property set lives in the DOM, so nothing in the engine changes
266+
// when an entry appears there. A name that was absent has to start resolving on the
267+
// node from the very next read, rather than staying on whatever the prototype said.
268+
[Test]
269+
public async Task NamedEntryStartsResolvingOnTheNodeAsSoonAsItExists()
270+
{
271+
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();
272+
Assert.AreEqual("undefined,t,true", result);
273+
}
274+
275+
[Test]
276+
public async Task AccessorDefinedOnANodeByScriptIsInvokedOnRead()
277+
{
278+
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();
279+
Assert.AreEqual("from getter,true", result);
280+
}
281+
282+
[Test]
283+
public async Task NonEnumerablePropertyOfANodeIsSeenButNotEnumerated()
284+
{
285+
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();
286+
Assert.AreEqual("true,false,0", result);
287+
}
288+
289+
[Test]
290+
public async Task PropertyAssignedToANodeIsCopiedAndSerialized()
291+
{
292+
var result = await "(function () { var d = document.createElement('div'); d.custom = 'v'; return JSON.stringify(d) + ',' + JSON.stringify(Object.assign({}, d)); })()".EvalScriptAsync();
293+
Assert.AreEqual("{\"custom\":\"v\"},{\"custom\":\"v\"}", result);
294+
}
131295
}
132296
}
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
namespace AngleSharp.Js.Tests
2+
{
3+
using Jint;
4+
using Jint.Native;
5+
using Jint.Native.Object;
6+
using Jint.Runtime;
7+
using Jint.Runtime.Descriptors;
8+
using NUnit.Framework;
9+
using System;
10+
using System.Collections.Generic;
11+
using System.Runtime.CompilerServices;
12+
13+
/// <summary>
14+
/// Turns Jint's host-contract verifiers on for this suite, and proves they are on.
15+
/// </summary>
16+
/// <remarks>
17+
/// <para>
18+
/// The verifiers are the checks that catch a host object answering one of the engine's
19+
/// extension points in a way that contradicts another - the exact hazard this binding lives
20+
/// with, because its proxies answer four of them: TryGetOwnPropertyValue, ProbeOwnProperty,
21+
/// HasIndex, and the semantics derived from not overriding Get. Every one of those is trusted
22+
/// by the engine and never re-checked on the hot path, so a wrong answer is silent: a member
23+
/// disappears from every enumeration, or a read that should have found an attribute resolves
24+
/// on the prototype instead. A verified run is the only thing that turns any of that into a
25+
/// failure.
26+
/// </para>
27+
/// <para>
28+
/// They used to be compiled out of Release, so reaching them meant building Jint from source
29+
/// in Debug. Since 4.15.3 the shipped package reads an AppContext switch instead, which is
30+
/// what makes this a thing every CI run can do against the very package the library ships
31+
/// against.
32+
/// </para>
33+
/// <para>
34+
/// The switch is read once, at the type initialization of the gate behind it, so it has to be
35+
/// set before the first use of any Jint type - a fixture, a one-time setup or a static
36+
/// constructor all run far too late. A module initializer is the only hook early enough by
37+
/// construction: the runtime runs it before any code of this assembly does, and Jint is only
38+
/// ever reached from this assembly's code.
39+
/// </para>
40+
/// </remarks>
41+
[TestFixture]
42+
public class HostContractVerificationTests
43+
{
44+
internal const String SwitchName = "Jint.EnableHostContractVerification";
45+
46+
[ModuleInitializer]
47+
internal static void EnableBeforeAnyJintTypeIsTouched() => AppContext.SetSwitch(SwitchName, true);
48+
49+
/// <summary>
50+
/// That the switch was set early enough, proven from behaviour rather than from the flag:
51+
/// a host whose probe contradicts its own descriptors throws exactly when something is
52+
/// verifying, and is quietly believed when nothing is.
53+
/// </summary>
54+
/// <remarks>
55+
/// It is deliberately not asserted through the gate itself, which is internal to Jint. If
56+
/// this fails, the whole suite has been running unverified and the proxies' hooks are
57+
/// covered by nothing.
58+
/// </remarks>
59+
[Test]
60+
public void TheVerifiersAreRunningForThisSuite()
61+
{
62+
var engine = new Engine();
63+
engine.SetValue("host", new SelfContradictingHost(engine));
64+
65+
var error = Assert.Throws<InvalidOperationException>(() => engine.Evaluate("Object.keys(host).join(',')"),
66+
"The host-contract verifiers must be on, or nothing checks this binding's own hooks.");
67+
68+
StringAssert.Contains("lied", error.Message);
69+
}
70+
71+
/// <summary>
72+
/// Denies through its probe one name its own GetOwnProperty plainly serves. The engine
73+
/// trusts the probe and never re-asks, so unverified the key simply vanishes from every
74+
/// enumeration with nothing to see.
75+
/// </summary>
76+
private sealed class SelfContradictingHost : ObjectInstance
77+
{
78+
public SelfContradictingHost(Engine engine)
79+
: base(engine)
80+
{
81+
}
82+
83+
public override PropertyDescriptor GetOwnProperty(JsValue property)
84+
{
85+
var name = property.ToString();
86+
return name == "honest" || name == "lied"
87+
? new PropertyDescriptor(name, true, true, true)
88+
: PropertyDescriptor.Undefined;
89+
}
90+
91+
// Seen as "protected" from outside the Jint assembly, which is what a host writes.
92+
protected override OwnPropertyProbe ProbeOwnProperty(JsValue property) =>
93+
property.ToString() == "lied" ? OwnPropertyProbe.Missing : base.ProbeOwnProperty(property);
94+
95+
public override List<JsValue> GetOwnPropertyKeys(Types types = Types.String | Types.Symbol) =>
96+
new List<JsValue> { new JsString("honest"), new JsString("lied") };
97+
}
98+
}
99+
}
100+
101+
#if !NET5_0_OR_GREATER
102+
namespace System.Runtime.CompilerServices
103+
{
104+
using System;
105+
106+
/// <summary>
107+
/// The attribute the compiler recognizes by name rather than by identity, so declaring it
108+
/// here is what gives the initializer above a target framework where the BCL carries none.
109+
/// </summary>
110+
[AttributeUsage(AttributeTargets.Method, Inherited = false)]
111+
sealed class ModuleInitializerAttribute : Attribute
112+
{
113+
}
114+
}
115+
#endif
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
namespace AngleSharp.Js.Tests
2+
{
3+
using AngleSharp.Dom;
4+
using AngleSharp.Js.Proxies;
5+
using AngleSharp.Scripting;
6+
using Jint;
7+
using Jint.Native.Object;
8+
using NUnit.Framework;
9+
using System;
10+
using System.Threading.Tasks;
11+
12+
/// <summary>
13+
/// Pins the property access semantics Jint derives for the proxy types.
14+
/// </summary>
15+
/// <remarks>
16+
/// Every proxy here is ordinary, and the engine works that out by itself: a type gets the
17+
/// short read path because it does not override Get, and loses it because it does. Nothing
18+
/// declares that anywhere, so adding a Get override - the obvious way to intercept a read -
19+
/// would silently move every read against that type onto the long path, with no test failing
20+
/// and no behaviour changing. These assertions are the alarm for that.
21+
/// </remarks>
22+
[TestFixture]
23+
public class PropertyAccessSemanticsTests
24+
{
25+
private static Task AssertOrdinaryAsync(String expression, Type expected) =>
26+
AssertOrdinaryAsync(expression, (engine, instance) =>
27+
// Assert what it is as well as how it reads: if the expression stops producing
28+
// the proxy under test the semantics assertion would still pass, and pass for
29+
// the wrong object.
30+
Assert.AreEqual(expected, instance.GetType(), expression + " produced an unexpected proxy type."));
31+
32+
private static async Task AssertOrdinaryAsync(String expression, Action<Engine, ObjectInstance> identify)
33+
{
34+
var context = BrowsingContext.New(Configuration.Default.WithJs());
35+
var document = await context.OpenAsync(m => m.Content(
36+
"<!doctype html><html><body><span id='s'>x</span></body></html>")).ConfigureAwait(false);
37+
var engine = context.GetService<JsScriptingService>().GetOrCreateJint(document);
38+
var value = engine.Evaluate(expression);
39+
40+
Assert.IsInstanceOf<ObjectInstance>(value, expression + " did not evaluate to an object.");
41+
42+
var instance = (ObjectInstance)value;
43+
44+
identify.Invoke(engine, instance);
45+
46+
Assert.AreEqual(PropertyAccessSemantics.Ordinary, engine.Advanced.GetPropertyAccessSemantics(instance),
47+
instance.GetType().Name + " must keep ordinary read semantics; overriding Get forfeits them.");
48+
49+
context.Dispose();
50+
}
51+
52+
[Test]
53+
public Task NodeProxyReadsAsOrdinary() =>
54+
AssertOrdinaryAsync("document.createElement('div')", typeof(DomNodeInstance));
55+
56+
[Test]
57+
public Task CollectionProxyReadsAsOrdinary() =>
58+
AssertOrdinaryAsync("document.getElementsByTagName('span')", typeof(DomCollectionInstance));
59+
60+
/// <summary>
61+
/// The prototype is identified by its representation rather than by its type, because it
62+
/// no longer has one of ours: it is an object over a shared member layout, and that is
63+
/// exactly the property carrying the win - a host subclass used as a prototype is refused
64+
/// as an inline-cache holder by design, an object in the shared layout is not.
65+
/// </summary>
66+
/// <remarks>
67+
/// The member read is what settles the answer: the representation is installed on first
68+
/// touch, and the diagnostic deliberately does not perturb an untouched object into it.
69+
/// </remarks>
70+
[Test]
71+
public Task PrototypeReadsAsOrdinary() =>
72+
AssertOrdinaryAsync(
73+
"(function () { var d = document.createElement('div'); d.tagName; return Object.getPrototypeOf(d); })()",
74+
(engine, instance) =>
75+
Assert.IsTrue(engine.Advanced.HasSharedShape(instance),
76+
"A DOM prototype must be an object over a shared member layout."));
77+
78+
[Test]
79+
public Task ConstructorReadsAsOrdinary() =>
80+
AssertOrdinaryAsync("HTMLDivElement", typeof(DomConstructorInstance));
81+
82+
[Test]
83+
public Task ConstructorFunctionReadsAsOrdinary() =>
84+
AssertOrdinaryAsync("Image", typeof(DomConstructorFunctionInstance));
85+
}
86+
}

0 commit comments

Comments
 (0)