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
185 changes: 185 additions & 0 deletions Jint.Tests.PublicInterface/HostGlobalPrototypeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,191 @@

namespace Jint.Tests.PublicInterface;

/// <summary>
/// A Proxy or a host object with a <c>get</c> hook installed as the prototype of <c>globalThis</c>. A bare
/// identifier resolves through the global's prototype chain, so a name such an object only produces from
/// its <c>[[Get]]</c> has to reach the identifier lane too — <c>[[GetOwnProperty]]</c> fires a Proxy's
/// <c>getOwnPropertyDescriptor</c> trap and never <c>get</c>, which would leave the read disagreeing with
/// <c>in</c>, with <c>typeof</c> and with the same read spelled <c>globalThis.name</c>.
/// </summary>
public class ExoticGlobalPrototypeTests
{
/// <summary>
/// Produces a value from <see cref="ObjectInstance.Get"/> for a name it owns no descriptor for — the
/// derived <see cref="PropertyAccessSemantics.Exotic"/> shape, and exactly a Proxy's problem. It
/// declares nothing: the engine derives the semantics from the override itself.
/// </summary>
private sealed class SynthesizingHost : ObjectInstance
{
private const string Virtual = "virt";

public SynthesizingHost(Engine engine) : base(engine)
{
}

public int Gets { get; private set; }

public JsValue LastReceiver { get; private set; } = JsValue.Undefined;

private static bool IsVirtual(JsValue property)
=> property.IsString() && string.Equals(property.AsString(), Virtual, StringComparison.Ordinal);

public override JsValue Get(JsValue property, JsValue receiver)
{
if (IsVirtual(property))
{
Gets++;
LastReceiver = receiver;
return "VIRTUAL";
}

return base.Get(property, receiver);
}

public override bool HasProperty(JsValue property) => IsVirtual(property) || base.HasProperty(property);
}

private const string InstallProxy = """
var proxy = new Proxy({}, {
has: function (t, k) { return k === 'virt' || Reflect.has(t, k); },
get: function (t, k, r) { if (k === 'virt') { gets++; receiver = r; return 'VIRTUAL'; } return Reflect.get(t, k, r); },
set: function (t, k, v, r) { sets++; setReceiver = r; return Reflect.set(t, k, v, r); }
});
var gets = 0, sets = 0, receiver = null, setReceiver = null;
Object.setPrototypeOf(globalThis, proxy);
""";

[Fact]
public void AProxyPrototypeSeesItsGetTrapForABareIdentifier()
{
var engine = new Engine();
engine.Execute(InstallProxy);

engine.Evaluate("virt").AsString().Should().Be("VIRTUAL");
engine.Evaluate("gets").AsNumber().Should().Be(1);
engine.Evaluate("receiver === globalThis").AsBoolean().Should().BeTrue();
}

[Fact]
public void AProxyPrototypeAnswersTypeofTheReadAndTheMemberReadAlike()
{
var engine = new Engine();
engine.Execute(InstallProxy);

engine.Evaluate("typeof virt").AsString().Should().Be("string");
engine.Evaluate("'virt' in globalThis").AsBoolean().Should().BeTrue();
engine.Evaluate("globalThis.virt").AsString().Should().Be("VIRTUAL");
engine.Evaluate("virt").AsString().Should().Be("VIRTUAL");
}

[Fact]
public void AProxyPrototypeSeesItsSetTrapForABareAssignment()
{
var engine = new Engine();
engine.Execute(InstallProxy);

engine.Execute("virt = 12;");

engine.Evaluate("sets").AsNumber().Should().Be(1);
engine.Evaluate("setReceiver === globalThis").AsBoolean().Should().BeTrue();
// Reflect.set forwarded onto the global receiver, so the name is now an own global that shadows
engine.Evaluate("globalThis.hasOwnProperty('virt')").AsBoolean().Should().BeTrue();
engine.Evaluate("virt").AsNumber().Should().Be(12);
}

/// <summary>
/// staging/sm/Proxy/global-receiver.js from test262, which the generated suite does not cover because
/// the harness only generates annexB, built-ins, intl402 and language.
/// </summary>
[Fact]
public void Test262StagingGlobalReceiver()
{
var engine = new Engine();
engine.Execute("""
var global = this;
var proto = Object.getPrototypeOf(global);
var gets = 0, sets = 0, getReceiver = null, setReceiver = null;

Object.setPrototypeOf(global, new Proxy(proto, {
has(t, id) { return id === "bareword" || Reflect.has(t, id); },
get(t, id, r) { gets++; getReceiver = r; return Reflect.get(t, id, r); },
set(t, id, v, r) { sets++; setReceiver = r; return Reflect.set(t, id, v, r); }
}));
""");

engine.Evaluate("bareword").Should().Be(JsValue.Undefined);
engine.Evaluate("gets").AsNumber().Should().Be(1);
engine.Evaluate("getReceiver === global").AsBoolean().Should().BeTrue();

engine.Execute("bareword = 12;");
engine.Evaluate("sets").AsNumber().Should().Be(1);
engine.Evaluate("setReceiver === global").AsBoolean().Should().BeTrue();
engine.Evaluate("global.bareword").AsNumber().Should().Be(12);
}

[Fact]
public void AHostPrototypeThatOverridesGetIsAskedThroughGet()
{
var engine = new Engine();
var host = new SynthesizingHost(engine);
engine.Global.Prototype = host;

engine.Evaluate("virt").AsString().Should().Be("VIRTUAL");
engine.Evaluate("typeof virt").AsString().Should().Be("string");
engine.Evaluate("'virt' in globalThis").AsBoolean().Should().BeTrue();
engine.Evaluate("globalThis.virt").AsString().Should().Be("VIRTUAL");

host.Gets.Should().BeGreaterThan(0);
ReferenceEquals(host.LastReceiver, engine.Global).Should().BeTrue();
}

[Fact]
public void AProxyBelowTheDirectPrototypeStillResolves()
{
var engine = new Engine();
engine.Execute(InstallProxy);
engine.Execute("Object.setPrototypeOf(globalThis, Object.create(proxy));");

engine.Evaluate("virt").AsString().Should().Be("VIRTUAL");
engine.Evaluate("typeof virt").AsString().Should().Be("string");
}

[Fact]
public void AnOrdinaryPrototypeIsUnaffected()
{
var engine = new Engine();
engine.Execute("""
var plain = Object.create(null);
plain.plainName = 'PLAIN';
Object.defineProperty(plain, 'accessor', { get: function () { return this === globalThis ? 'RECEIVER' : 'OTHER'; } });
Object.setPrototypeOf(globalThis, plain);
""");

engine.Evaluate("plainName").AsString().Should().Be("PLAIN");
engine.Evaluate("typeof plainName").AsString().Should().Be("string");
engine.Evaluate("globalThis.plainName").AsString().Should().Be("PLAIN");
// an inherited accessor is still invoked with the global as its `this`
engine.Evaluate("accessor").AsString().Should().Be("RECEIVER");
// and a name absent from the whole chain is still unresolvable
engine.Evaluate("typeof missing").AsString().Should().Be("undefined");
Invoking(() => engine.Evaluate("missing")).Should().Throw<Jint.Runtime.JavaScriptException>();
}

[Fact]
public void AnOrdinaryPrototypeTwoLevelsDeepIsUnaffected()
{
var engine = new Engine();
engine.Execute("""
var base = Object.create(null);
base.deepName = 'DEEP';
Object.setPrototypeOf(globalThis, Object.create(base));
""");

engine.Evaluate("deepName").AsString().Should().Be("DEEP");
engine.Evaluate("typeof deepName").AsString().Should().Be("string");
}
}

/// <summary>
/// A wrapped CLR object installed as the prototype of <c>globalThis</c> (issue #2925). Only the global
/// object participates in bare-identifier resolution, so this is where the spec-shaped
Expand Down
33 changes: 29 additions & 4 deletions Jint/Runtime/Environments/GlobalEnvironment.cs
Original file line number Diff line number Diff line change
Expand Up @@ -149,12 +149,25 @@ private bool TryGetFromGlobalPrototype(
JsValue name,
[NotNullWhen(true)] out JsValue? value)
{
var prototype = _global._prototype!;

// An own-property question is not a read for an object whose [[Get]] deviates from the ordinary
// one — PropertyAccessSemantics.Exotic, which the engine derives per type and the exotic built-ins
// state outright. A Proxy answers [[GetOwnProperty]] from its getOwnPropertyDescriptor trap and
// never its get trap, and a host object that synthesises a value in Get owns no descriptor for it
// at all, so either would resolve to nothing here while HasBinding — a real [[HasProperty]] chain
// walk — has already said the binding exists. Hand those the same spec-shaped pair the deeper
// levels get, one level higher up.
if ((prototype._type & InternalTypes.ExoticGet) != InternalTypes.Empty)
{
return TryGetFromPrototypeChain(prototype, name, out value);
}

// TryGetOwnPropertyValue's base body is exactly what this used to spell out — GetOwnProperty,
// Undefined means no, otherwise UnwrapJsValue against the global as receiver — so for every
// in-box prototype the two are the same call. Asking through the hook additionally lets a host
// object installed as the global's prototype answer from its own state without building a
// descriptor it would only throw away.
var prototype = _global._prototype!;
if (prototype.TryGetOwnPropertyValue(name, _global, out var found))
{
value = found;
Expand All @@ -163,10 +176,22 @@ private bool TryGetFromGlobalPrototype(

// deeper levels are colder: spec-shaped [[HasProperty]] + [[Get]] with the global as
// receiver, so a Proxy or exotic object below the direct prototype sees its real traps
var parent = prototype.GetPrototypeOf();
if (parent is not null && parent.HasProperty(name))
return TryGetFromPrototypeChain(prototype.GetPrototypeOf(), name, out value);
}

/// <summary>
/// [[HasProperty]] to decide whether the name resolves at all, then [[Get]] with the global as the
/// receiver — the spec's GlobalEnvironmentRecord.GetBindingValue shape, which the shortcut above can
/// only stand in for when the object it asks has ordinary read semantics.
/// </summary>
private bool TryGetFromPrototypeChain(
ObjectInstance? start,
JsValue name,
[NotNullWhen(true)] out JsValue? value)
{
if (start is not null && start.HasProperty(name))
{
value = parent.Get(name, _global);
value = start.Get(name, _global);
return true;
}

Expand Down