Illustration by Sue Lockwood
TC-39, the standards body that defines JavaScript, maintains a gigantic suite of tests for the language. The name of that test suite is Test262. When we started extending Test262 to cover brand new language features, we knew we were in for some surprises.
Even so, we never could have anticipated the horrors we would uncover.
JavaScript is a powerful language. The abstractions it provides allows developers to express complex algorithms with a small amount of (usually readable) code. However, these language features can sometimes interact in strange ways. We saw this time and again in the 18 months since we started collaborating with the V8 team.
In this blog post, we have assembled some of the more bizarre tests we contributed–our own menagerie of delinquent code. All of it is valid JavaScript, being fully defined by the language specification, ECMA262. None of it would be accepted in a code review.
So today, I will be your tour guide through the grotesque corners of the language. Leave your scruples at the door, and if you have a heart condition, please consider sitting this one out.
The Sideshow
Exhibit #1: Destructuring & Generators
To begin, consider destructuring assignment
(spec,
MDN).
Code like [x, y] = [1, 2]
assigns the value 1
to x
and 2
to y
. Behind
the scenes, the runtime retrieves an iterator for the [1, 2]
array, and
advances it for each “binding” (x
and y
), assigning the iterated value as
it goes along.
It can get more complicated than that. Just like in non-destructuring
assignment, the targets could themselves be complex expressions. Think
[foo.bar] = [3]
or even [baz["q" + "ux"]] = [4]
. Depending on the result
of those expressions, the iteration mentioned above could be interrupted. Our
first exhibit demonstrates how messy this can be:
var returnCount = 0;
var unreachable = 0;
var iterable = {};
var iterator = {
return: function() {
returnCount += 1;
return {};
}
};
var iter, result;
iterable[Symbol.iterator] = function() {
return iterator;
};
function* g() {
[ {}[yield] ] = iterable;
unreachable += 1;
}
iter = g();
iter.next();
result = iter.return(777);
assert.sameValue(returnCount, 1);
assert.sameValue(unreachable, 0, 'Unreachable statement was not executed');
assert.sameValue(result.value, 777);
assert(result.done, 'Iterator correctly closed');
(source: language/expressions/assignment/dstr-array-elem-iter-rtrn-close.js)
Here, we have placed the destructuring assignment within a generator function
(spec,
MDN)
and included a yield
expression in a position that is evaluated during
iteration. This allows us to interrupt the iteration right in the middle of
things—we call the little-known
iter.return()
(spec,
MDN)
instead of iter.next()
.
All of this allows us to assert the intended behavior when iteration is
interrupted at this step. The call to return
triggers the creation of a
“return completion,” which is similar to a “throw completion” created by a
throw
statement (we have a test for that,
too).
The custom iterable
allows us to observe the expected behavior.
They say, “beauty is in the eye of the beholder,” but you’d be hard-pressed to find anyone willing to stare at this thing for very long.
Exhibit #2: Tail Call Optimization & Tagged Templates
Sometimes called “TCO” for short, tail call optimization (spec, 2ality) allows for recursive function calls to clean up after themselves before they are technically done executing. This occurs automatically for functions authored in the correct way, and it can be useful for computationally-expensive algorithms that use a “divide and conquer” strategy.
Here, we’re making sure that the optimization occurs for functions invoked using ES2015’s new “tagged template” feature (spec, MDN):
(function() {
var finished = false;
function getF() {
return f;
}
function f(_, n) {
if (n === 0) {
finished = true;
return;
}
return getF()`${n-1}`;
}
f(null, 100000);
return finished;
}());
(source: language/expressions/tagged-template/tco-call.js)
Just as there is no special syntax to enable TCO, there is no cut-and-dry way to ensure it is taking place. So in this test, we are ensuring that TCO is occurring by recursing a huge number of times. If the optimization is not enabled, this test will “fail” because the program will exhaust available memory resources and crash. This makes the test a little rude, but you can’t expect good manners from a creature that has been forgotten by nature.
(By the way, the same implicitness that makes this test hard to read and write has prompted a larger discussion about re-working this feature to require explicit syntax.)
Exhibit #3: Typed Arrays & Reflect
When you create a typed
array (spec,
MDN)
from another typed array, generally you would expect the new one to be derived
from the same class. For instance, new Int8Array(anotherInt8Array)
produces a
second Int8Array
. The subtle truth is that the constructor to be used is
actually defined by the NewTarget value
(spec,
MDN).
This distinction normally doesn’t matter because most applications invoke
constructors with the new
keyword. Doing so sets the NewTarget value to the
constructor, resulting in the behavior described by the Int8Array
example
above.
However! ES2015 introduces the Reflect
API
(spec,
MDN),
and Reflect.construct
allows you to invoke a constructor with any arbitrary
value for NewTarget. We’re doing just that in the following test:
function newTarget() {}
newTarget.prototype = null;
var sample = new Int8Array(8);
var ta = Reflect.construct(Int8Array, [sample], newTarget);
assert.sameValue(ta.constructor, Int8Array);
assert.sameValue(Object.getPrototypeOf(ta), Int8Array.prototype);
(source: built-ins/TypedArrays/typedarray-arg-use-default-proto-if-custom-proto-is-not-object.js)
“But wait!” you’re saying, “The NewTarget value isn’t being honored at all!
ta
‘s prototype is still the Int8Array
prototype.” Well, that’s exactly the
detail that landed this test in the sideshow. If, for whatever reason, the
NewTarget value has a non-object prototype (as this one does), then the
specification dictates the “active function” (Int8Array
here) should be used
instead. Chilling.
We’re about to enter the show’s “Promise” tent. I hope you’re ready, because this one has two separate exhibits.
Exhibit #4: Promises & Arrays
Promises (spec,
MDN)
are a powerful way to orchestrate asynchronous operations. One important method
for managing these operations is Promise.all
: it allows for defining what
should happen once many operations complete successfully.
This is essentially a convenience method; it’s something that JavaScript developers can write (and have written) on their own. The specification for its behavior reflects this because it uses a similar approach as a so-called “user land” version would.
Specifically: the method is expected to create an array of “resolution values,”
and pass that array on to the internal Promise mechanism. Later, this array is
itself interpreted as a resolution value. Because such values may represent
further asynchronous operations (they’re called “thenables” in that case),
bizarre extensions to Array
can trigger surprising results…
var value = {};
var nonThenable = [];
var promise;
nonThenable.then = null;
promise = Promise.all(nonThenable);
Object.defineProperty(Array.prototype, 'then', {
get: function() {
throw value;
}
});
promise.then(function() {
$DONE('The promise should not be fulfilled.');
}, function(val) {
if (val !== value) {
$DONE('The promise should be rejected with the expected value.');
return;
}
$DONE();
});
(source: built-ins/Promise/all/resolve-poisoned-then.js)
We’re passing an empty array to Promise.resolve
because we aren’t actually
interested in control flow for this test. This is valid because the method
follows the same basic steps regardless of the number of elements provided. It
is still expected to create a completely new array and store that as a
resolution value. (We’ve “nulled out” the then
property of the nonThenable
array to drive home the fact that the trouble starts with yet another unseen
array.)
When the time comes to resolve the promise, the runtime interprets this hidden
array like any other resolution value. The runtime makes no special
consideration for the fact that it created this array itself, so it begins by
checking to see if the value is “thenable.” In this test, we’ve damaged the
Array
prototype with a “poisoned” then
property—one that throws an
error when accessed. That’s why we expect the Promise to be rejected.
While it might not be easy to see why the runtime rejects this Promise, it’s obvious why society has rejected this test.
Exhibit #5: Promises & …well, more Promises
One of the most powerful aspects of Promises is their ability to be “chained.” By resolving one Promise with another, we’re able to schedule events to happen in series in a very natural way. (We saw some of the mechanics for this laid bare in the previous exhibit.)
Naively designed, this feature could enable disastrous bugs: if a Promise is resolved with a reference to itself, then the runtime could enter into an infinite loop. This sounds kind of abstract, but it’s not really difficult to demonstrate:
// The arrow function is invoked asynchronously, *after*
// the promise has been created and assigned to `p`.
var p = Promise.resolve().then(() => p);
Luckily, ES2015 was not naively designed. There are specific guards in place
for exactly this condition, so in the example above, the runtime calls the
arrow function, recognizes that the return value is the same as the promise
itself, and proceeds to reject p
with a TypeError
.
Promise.prototype.then
has to be protected from this case because the
provided callback is invoked asynchronously, after the promise has been
created.
Compare this with Promise.resolve
. This method synchronously creates a
Promise that has been resolved with some value. At first glance, the
“self-resolution” problem doesn’t seem relevant here:
// The promise is created *before* the value is assigned to
// `p1`, so this promise is resolved with the value `undefined`.
var p1 = Promise.resolve(p1);
// ...and unlike `Promise.prototype.then`, `Promise.resolve`
// doesn't invoke function arguments, so this promise is
// resolved with the arrow function (not its return value).
var p2 = Promise.resolve(() => p2);
Believe it or not, Promise.resolve
can return a Promise that has been
resolved with itself. It just takes a little more effort:
var resolve, reject;
var only = new Promise(function(_resolve, _reject) {
resolve = _resolve;
reject = _reject;
});
var P = function(executor) {
executor(resolve, reject);
return only;
};
Promise.resolve.call(P, only)
.then(function() {
$DONE('The promise should not be fulfilled.');
}, function(value) {
if (!value) {
$DONE('The promise should be rejected with a value.');
return;
}
if (value.constructor !== TypeError) {
$DONE('The promise should be rejected with a TypeError instance.');
return;
}
$DONE();
});
(source: built-ins/Promise/resolve/resolve-self.js)
In order to demonstrate this behavior, we have to create a custom Promise
constructor. This “constructor” returns a valid Promise instance, but it always
returns the same Promise instance named only
. So when we use that
constructor as the “this” value for Promise.resolve
(via
Function.prototype.call
—spec,
MDN),
Promise.resolve
attempts to create a new Promise but winds up using the
only
Promise instance. Since we’re also specifying the only
promise as the
resolution value, this triggers that same guard we saw above, resulting in the
expected TypeError
.
Okay, the Promises are getting anxious. We should move along.
Exhibit 6: Default Parameters & eval
One of the more radical additions in ES2015 is the default parameter (spec, MDN. I’m sure many would disagree, but I consider it so severe because it allows for arbitrary expressions in a completely novel position. When it comes to defining this feature, it’s not enough to simply say, “You can write expressions here now, have fun.” The specification authors had to carefully consider all the implications, and they identified some surprising edge cases. Which, of course, we need to test.
Among the many things you might write in the position of a default parameter value is a direct eval (spec, MDN). This much-maligned feature has a unique property: it is an expression that can create a variable binding. This means that we need to define (and test) what happens when a default parameter itself creates a variable.
var x = 'outside';
var probe1, probe2, probeBody;
(function(
_ = (eval('var x = "inside";'), probe1 = function() { return x; }),
__ = probe2 = function() { return x; }
) {
probeBody = function() { return x; };
}());
assert.sameValue(probe1(), 'inside');
assert.sameValue(probe2(), 'outside');
assert.sameValue(probeBody(), 'outside');
(source: language/expressions/function/scope-param-elem-var-close.js)
The specification dictates that there should be a dedicated variable scope for
the default parameter. It goes further to say that each parameter should have
its own variable scope. That’s why in this test, we have defined both
probe1
and probe2
; only probe1
should “see” the x
binding created by
eval
. Implementors who simply create a single scope shared by all parameters
will not pass this test.
…and that concludes today’s tour through the sideshow. If you’re in the mood for more thrills, feel free to wander around at your heart’s content.
The Value of Things in Cages
It can be fun to gawk at the peculiarities of a programming language (“Wat” by Gary Bernhardt being a favorite). But while a healthy sense of irony is a requirement for all programmers, this exhibition doesn’t exist just to poke fun at JavaScript.
Many years ago, Bocoup’s own Ben
Alman wrote about the various
idiosyncracies in try
statements across
browsers. Back then, it seemed all too easy for a bug to find its way into a
web browser. Shortly after that, some major web site/library would come to
depend on that behavior, and thenceforth the web platform would be stuck with
the
bug.
In Test262, we have a tool to automatically verify specification conformance with great precision. Not only that; the practice of writing tests for early-stage features often exposes errors and ambiguities in their design.
If this strange pageant is any indication, the test suite doesn’t prevent monsters from finding their way into the language… but that isn’t the goal. In aspiring to be a repository of all novel interactions, Test262 brings us closer to a world where every edge case is thoroughly understood and accepted before it makes its way into our application code. Consider this post a celebration of the ever-improving web platform.