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
145 changes: 143 additions & 2 deletions Jint.Tests/Runtime/WebApi/ConsoleTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,148 @@ public void RendersFunctionsSymbolsAndErrors()
Run("console.log(Symbol('s'))").Should().Equal("Symbol(s)");
Run("console.log(10n)").Should().Equal("10n");
Run("console.log(new TypeError('bad'))").Should().Equal("TypeError: bad");
Run("console.log(function foo() {})").Should().Equal("function foo() { [native code] }");
}

/// <summary>
/// https://github.com/sebastienros/jint/issues/3316. A promise owns no enumerable property, so walking
/// it as an ordinary object rendered the empty one every other engine renders as its state.
/// </summary>
[Fact]
public void RendersAPromiseInEachOfItsThreeStates()
{
Run("console.log(new Promise(() => {}))").Should().Equal("Promise { <pending> }");
Run("console.log(Promise.resolve(42))").Should().Equal("Promise { 42 }");
Run("var p = Promise.reject(new TypeError('bad')); p.catch(() => {}); console.log(p)")
.Should().Equal("Promise { <rejected> TypeError: bad }");

// The issue's own shape: the promise reached through an array.
Run("console.log([new Promise(() => {})])").Should().Equal("[ Promise { <pending> } ]");

// What the specification decides was never wrong and does not move. Only the rendering the Console
// Standard leaves to the implementation changed.
Run("console.log(String(Promise.resolve()))").Should().Equal("[object Promise]");
Run("console.log(Object.prototype.toString.call(Promise.resolve()))").Should().Equal("[object Promise]");
}

/// <summary>
/// The rest of the family the promise belongs to. Each rendering is read from the object's internal
/// slots, never through the prototype accessor of the same name — <c>source</c>, <c>flags</c>,
/// <c>byteLength</c> and <c>toISOString</c> are configurable on every one of these.
/// </summary>
[Fact]
public void RendersTheWellKnownExoticObjects()
{
Run("console.log(new Map())").Should().Equal("Map(0) {}");
Run("console.log(new Map([['a', 1], [2, 'b']]))").Should().Equal("Map(2) { 'a' => 1, 2 => 'b' }");
Run("console.log(new Set())").Should().Equal("Set(0) {}");
Run("console.log(new Set([1, 'two']))").Should().Equal("Set(2) { 1, 'two' }");

// Nothing enumerates a weak collection, and a WeakRef is not dereferenced: reaching its target is
// what WeakRef.prototype.deref exists to gate.
Run("console.log(new WeakMap())").Should().Equal("WeakMap { <items unknown> }");
Run("console.log(new WeakSet())").Should().Equal("WeakSet { <items unknown> }");
Run("console.log(new WeakRef({}))").Should().Equal("WeakRef { <target unknown> }");

Run("console.log(new Date(Date.UTC(2020, 0, 1)))").Should().Equal("2020-01-01T00:00:00.000Z");
Run("console.log(new Date(NaN))").Should().Equal("Invalid Date");
Run("console.log(/ab+c/gi)").Should().Equal("/ab+c/gi");

Run("console.log(new Uint8Array([1, 2, 3]))").Should().Equal("Uint8Array(3) [ 1, 2, 3 ]");
Run("console.log(new BigInt64Array([1n, 2n]))").Should().Equal("BigInt64Array(2) [ 1n, 2n ]");
Run("console.log(new Uint8Array(0))").Should().Equal("Uint8Array(0) []");
Run("console.log(new ArrayBuffer(8))").Should().Equal("ArrayBuffer { byteLength: 8 }");
Run("console.log(new DataView(new ArrayBuffer(8), 2, 4))")
.Should().Equal("DataView { byteLength: 4, byteOffset: 2, buffer: ArrayBuffer { byteLength: 8 } }");

// A detached buffer has no length to read, which is what keeps the walk off bytes that are gone.
Run("var b = new ArrayBuffer(8); var v = new Uint8Array(b); b.transfer(); console.log(v); console.log(b)")
.Should().Equal("Uint8Array(0) []", "ArrayBuffer { (detached), byteLength: 0 }");

Run("console.log(new String('x'))").Should().Equal("[String: 'x']");
Run("console.log(new Number(5))").Should().Equal("[Number: 5]");
Run("console.log(new Boolean(true))").Should().Equal("[Boolean: true]");
Run("console.log(Object(Symbol('s')))").Should().Equal("[Symbol: Symbol(s)]");
Run("console.log(Object(10n))").Should().Equal("[BigInt: 10n]");

Run("(function () { console.log(arguments); })(1, 'a')").Should().Equal("[Arguments] { '0': 1, '1': 'a' }");

// Object.create(null) inherits no toString, so it is labelled rather than left to read as a literal.
Run("console.log(Object.create(null))").Should().Equal("[Object: null prototype] {}");
Run("var o = Object.create(null); o.a = 1; console.log(o)").Should().Equal("[Object: null prototype] { a: 1 }");
}

/// <summary>
/// A function is named, not printed: <c>Function.prototype.toString</c> answers the whole source text
/// once the engine retains it, and one console record carrying a function body is exactly the unbounded
/// output the rest of the renderer is written to avoid.
/// </summary>
[Fact]
public void NamesAFunctionInsteadOfPrintingIt()
{
Run("console.log(function foo() {})").Should().Equal("[Function: foo]");
Run("console.log(function () {})").Should().Equal("[Function (anonymous)]");
Run("console.log(() => {})").Should().Equal("[Function (anonymous)]");
Run("console.log(async function bar() {})").Should().Equal("[AsyncFunction: bar]");
Run("console.log(function* gen() {})").Should().Equal("[GeneratorFunction: gen]");
Run("console.log(async function* agen() {})").Should().Equal("[AsyncGeneratorFunction: agen]");
Run("console.log(class Foo {})").Should().Equal("[class Foo]");
Run("console.log(class {})").Should().Equal("[class (anonymous)]");
Run("console.log(Math.max)").Should().Equal("[Function: max]");

// `name` is configurable on every function, so a script can make reading it observable. Such a
// function reports as anonymous rather than turning the console into a way to run the accessor.
Run("var f = function foo() {}; Object.defineProperty(f, 'name', { get() { throw new Error('name ran'); } }); console.log(f)")
.Should().Equal("[Function (anonymous)]");

var sink = new RecordingSink();
var engine = new Engine(options =>
{
options.RetainFunctionSourceText = true;
options.UseConsole(sink);
});

engine.Execute("function withABody() { return 'a body nobody asked the console for'; } console.log(withABody)");
sink.Messages.Should().Equal("[Function: withABody]");
}

/// <summary>
/// The class promises it is not a way to run script. A proxy is where that promise was untrue: walking
/// one calls its <c>ownKeys</c> and <c>getOwnPropertyDescriptor</c> traps, so it renders as its target.
/// </summary>
[Fact]
public void NeverRunsScriptWhileInspecting()
{
Run(@"var p = new Proxy({ a: 1 }, {
ownKeys() { throw new Error('ownKeys ran'); },
getOwnPropertyDescriptor() { throw new Error('getOwnPropertyDescriptor ran'); },
get() { throw new Error('get ran'); },
});
console.log(p)").Should().Equal("{ a: 1 }");

// A revoked proxy has no target at all, and every trap on it throws.
Run("var r = Proxy.revocable({ a: 1 }, {}); r.revoke(); console.log(r.proxy)").Should().Equal("<Revoked Proxy>");

// Symbol.toStringTag is an ordinary accessor a script may install, and nothing consults it.
Run("console.log({ get [Symbol.toStringTag]() { throw new Error('tag ran'); }, a: 1 })")
.Should().Equal("{ a: 1 }");
}

[Fact]
public void AnExoticContainerIsDepthCappedAndCycleSafe()
{
Run("var m = new Map(); m.set('self', m); console.log(m)").Should().Equal("Map(1) { 'self' => [Circular] }");
Run("var s = new Set(); s.add(s); console.log(s)").Should().Equal("Set(1) { [Circular] }");

Run("console.log({ a: { b: { c: new Map([[1, 2]]) } } })").Should().Equal("{ a: { b: { c: [Map] } } }");
Run("console.log({ a: { b: { c: new Set([1]) } } })").Should().Equal("{ a: { b: { c: [Set] } } }");
Run("console.log({ a: { b: { c: Promise.resolve(1) } } })").Should().Equal("{ a: { b: { c: [Promise] } } }");
Run("console.log({ a: { b: { c: new Uint8Array(1) } } })").Should().Equal("{ a: { b: { c: [Uint8Array] } } }");

Run("var s = new Set(); for (var i = 0; i < 105; i++) s.add(i); console.log(s)")
.Should().ContainSingle().Which.Should().EndWith("99, ... 5 more items }");

Run("var m = new Map(); for (var i = 0; i < 105; i++) m.set(i, i); console.log(m)")
.Should().ContainSingle().Which.Should().EndWith("99 => 99, ... 5 more items }");
}

[Fact]
Expand Down Expand Up @@ -412,7 +553,7 @@ public void TableFallsBackToLoggingWhatCannotBeParsedAsTabular()
Run("console.table(42)").Should().Equal("42");
Run("console.table(null)").Should().Equal("null");
Run("console.table()").Should().Equal("undefined");
Run("console.table(function foo() {})").Should().Equal("function foo() { [native code] }");
Run("console.table(function foo() {})").Should().Equal("[Function: foo]");
}

[Fact]
Expand Down
20 changes: 16 additions & 4 deletions Jint/Native/Date/DatePrototype.cs
Original file line number Diff line number Diff line change
Expand Up @@ -980,8 +980,7 @@ private JsValue ToUtcString(JsValue thisObject)
[JsFunction]
private JsValue ToISOString(JsValue thisObject)
{
var thisTime = ThisTimeValue(thisObject);
var t = thisTime;
var t = ThisTimeValue(thisObject);

// Step 4 is "If tv is NaN, throw a RangeError exception", and in the spec that is the whole of
// "not a finite time value": a [[DateValue]] is either NaN or an integral Number inside the
Expand All @@ -993,10 +992,23 @@ private JsValue ToISOString(JsValue thisObject)
Throw.RangeError(_realm, "Invalid time value");
}

if (((JsDate) thisObject).DateTimeRangeValid)
return FormatIsoString((JsDate) thisObject);
}

/// <summary>
/// Steps 5 to 7 of <c>toISOString</c> on their own, over a date whose time value the caller has already
/// established is finite. Split out so a diagnostic renderer — the console's inspection of a
/// <see cref="JsDate"/> — reaches the same characters this method emits without calling
/// <c>toISOString</c>, which is configurable and so may be a script's own function by then.
/// </summary>
internal static string FormatIsoString(JsDate date)
{
var t = date._dateValue;

if (date.DateTimeRangeValid)
{
// shortcut
var dt = thisTime.ToDateTime();
var dt = t.ToDateTime();
return $"{dt.Year:0000}-{dt.Month:00}-{dt.Day:00}T{dt.Hour:00}:{dt.Minute:00}:{dt.Second:00}.{dt.Millisecond:000}Z";
}

Expand Down
34 changes: 34 additions & 0 deletions Jint/Native/Function/Function.cs
Original file line number Diff line number Diff line change
Expand Up @@ -684,6 +684,40 @@ internal string GetOwnFunctionName()
return nameValue.IsUndefined() ? "" : TypeConverter.ToString(nameValue);
}

/// <summary>
/// The function's own <c>name</c> for a diagnostic rendering — the console's <c>[Function: foo]</c> tag —
/// or <see langword="null"/> when it cannot be read without running script.
/// <para>
/// This is <see cref="GetOwnFunctionName"/>'s pending-descriptor handling with
/// <see cref="GetOwnFunctionNameForMessage"/>'s refusal to coerce. The pending sentinel is answered from
/// the definition's cached <c>JsName</c>, exactly as materializing it would, so an ordinary
/// <c>function foo() {}</c> reports <c>foo</c> without its descriptor being allocated. Past that, a
/// script may have replaced <c>name</c> — it is configurable on every function — with an accessor or with
/// an object whose <c>toString</c> is observable, and a console that renders a function must never be a
/// way to run either. Both answer <see langword="null"/>, which the caller renders as anonymous.
/// </para>
/// </summary>
internal string? GetOwnFunctionNameForDisplay()
{
var descriptor = _nameDescriptor;
if (descriptor is null)
{
return string.Empty;
}

if (ReferenceEquals(descriptor, _pendingDescriptor))
{
return _functionDefinition?.JsName?.ToString() ?? string.Empty;
}

if (descriptor.IsAccessorDescriptor())
{
return null;
}

return descriptor.Value is JsString name ? name.ToString() : null;
}

/// <summary>
/// The function's own <c>name</c> for an error message, rendered without ever running script.
/// <para>
Expand Down
2 changes: 2 additions & 0 deletions Jint/WebApi/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ Feature flags live in `WebApiFeatures`, whose bit layout is fixed ahead of the i

**The events feature is the DOM model minus the tree, and two of its choices are deliberate rather than partial.** `JsEventTarget.DispatchEvent` (`Jint/WebApi/Events/JsEventTarget.cs`) implements the specification's dispatch over the single-item path a tree-less target produces — which is the whole algorithm for an `EventTarget` author code created, not an approximation of it. Both passes still run, so `capture` decides *which* pass a listener runs in (a capturing listener on a flat target therefore runs before a non-capturing one whatever order they were registered in), `stopPropagation` ends the dispatch after the current pass while `stopImmediatePropagation` also breaks out of it, each pass walks a clone so a listener added mid-dispatch does not run and one removed does not either, and a `once` listener is removed before it is invoked. **A listener that throws erupts unless the host set a `DiagnosticsSink`**: the specification's "report the exception" (inner invoke step 2.10) needs somewhere to report to, and with a sink that is exactly what happens — the dispatch carries on to the next listener. Without one, swallowing the exception would lose it entirely, so it propagates, the same contract a timer callback has, with the dispatch state unwound in a `finally` either way. Both halves are pinned: `EventTargetTests.AListenerThatThrowsEruptsFromDispatchEvent` for the sinkless engine and `DiagnosticsTests.AThrowingListenerIsReportedAndTheDispatchContinues` for the other, and neither may be "fixed" into agreeing with the other. Two smaller things worth knowing before editing: `AbortSignal`'s abort algorithms (internal `AddAbortAlgorithm`/`RemoveAbortAlgorithm`) run **before** the `abort` event and are what `fetch` will hang its `CancellationToken` on, which is why the engine cancels that token first of all; and `AbortSignal.timeout()` is an entry on the very `TimerQueue` above, so the events feature creates `Engine._webApi` even when the timers flag is off, and a timeout signal fires only while the engine is pumped.

**The console's object rendering is bounded first and Node-compatible second, and the order matters because the two conflict.** `ConsoleFormatter` (`Jint/WebApi/Console/ConsoleFormatter.cs`) serves `%o`, `%O`, `console.dir`, every non-string top-level argument and every `console.table` cell, and the Console Standard says nothing normative about any of them — [Formatter](https://console.spec.whatwg.org/#formatter) hands `%o` to "optimally useful formatting" and `table`'s entire normative text is one sentence followed by "TODO: This will need a good algorithm." So the target is what Node and QuickJS emit, because the only thing *correct* can mean for a diagnostic is that a script author recognizes it: `Promise { <pending> }`, `Map(1) { 'a' => 1 }`, `Uint8Array(3) [ 1, 2, 3 ]`, `[String: 'x']`, `[Function: foo]`, `[Object: null prototype] {}`. **Every one of those readings is an internal-slot read**, and that is the rule to keep rather than the table: `source`, `flags`, `byteLength`, `toISOString` and `name` are all configurable, so reading a value *through the property of that name* is calling whatever a script left there. `Function.GetOwnFunctionNameForDisplay` and `DatePrototype.FormatIsoString` exist for exactly this, and a new exotic gets the same treatment or it does not get rendered. **A proxy renders as its target and is unwrapped before anything else looks at it** — walking one reaches the `ownKeys` and `getOwnPropertyDescriptor` traps, and until #3316 that made `console.log(proxy)` a way to run arbitrary script and to throw out of a log statement; `ConsoleTests.NeverRunsScriptWhileInspecting` is what stops that coming back. **A function is named, never printed**, because `Options.RetainFunctionSourceText` turns `Function.prototype.toString` into a whole function body in one record, and an unbounded record is the one thing the depth and entry caps exist to prevent. Two divergences from Node follow from that same bound and are deliberate: an `ArrayBuffer` carries no `[Uint8Contents]` hex dump, whose length would be the buffer's length, and own extra properties on an exotic are not appended, so a `Map` shows its entries and nothing else. **A third divergence is a scope line rather than a bound**, and it is the one a reader notices first: nothing is labelled by its constructor or its `Symbol.toStringTag`, so `Math` renders `{}` where Node renders `Object [Math] {}`, and a class instance renders `{ x: 1 }` where Node renders `Point { x: 1 }`. Adding it is possible without running script — the prototype's own `constructor` slot read as a *descriptor*, then `Function.GetOwnFunctionNameForDisplay` — but every step of that walk has to refuse an accessor, and the label is worth less than the refusals cost. Pick it up deliberately or not at all; do not reach for `obj.Get("constructor")` on the way.

#### Diagnostics and reportError

**`DiagnosticsSink` (`Jint/WebApi/DiagnosticsSink.cs`) is the one channel for the script errors a host cannot catch**, and the only web-API surface no feature flag governs: `Options.WebApi.Diagnostics.Sink` arms it on its own, so an engine that named no feature at all still gets it (`Options.Apply`'s condition, and the early return at the top of `WebApiRegistration.Apply` that gives such an engine the channel and *no* globals — not even `DOMException`). `WebApiFeatures.Reporting` (`1 << 15`) governs only the `reportError` global, whose whole implementation is HTML's *report an exception* reduced to its last step: this engine's global object is not an `EventTarget`, so nothing is fired, *notHandled* stays true, and "the user agent may report exception to a developer console" is the sink. Without a sink `reportError` is a no-op that never throws; its one failure is WebIDL's arity error for a bare `reportError()`.
Expand Down
Loading