diff --git a/INTERPRETING.md b/INTERPRETING.md index 9526b20f889..63ae7921464 100644 --- a/INTERPRETING.md +++ b/INTERPRETING.md @@ -81,6 +81,7 @@ properties of the global scope prior to test execution. Use this property to test that ECMAScript algorithms aren't mis-implemented to treat `document.all` as being `undefined` or of type Undefined (instead of Object). **Tests using this function must be tagged with the `IsHTMLDDA` feature so that only hosts supporting this property will run them.** + - **`safeResolvePromise`** - (present only in implementations that can provide it) a function which takes as its first argument a promise and as its second argument a value, and uses the SafeResolve semantics provided by the [Thenable Curtailment](https://github.com/tc39/proposal-thenable-curtailment) proposal to resolve the provided promise with the value. - **`agent`** - an ordinary object with the following properties: - **`start`** - a function that takes a script source string and runs the script in a concurrent agent. Will block until that agent is diff --git a/features.txt b/features.txt index ac9cec96a5c..952a5851bdc 100644 --- a/features.txt +++ b/features.txt @@ -77,6 +77,10 @@ error-stack-accessor # https://github.com/tc39/proposal-iterator-join Iterator.prototype.join +# Thenable Curtailment +# https://github.com/tc39/proposal-thenable-curtailment +thenable-curtailment + ## Standard language features # # Language features that have been included in a published version of the @@ -273,3 +277,4 @@ __setter__ IsHTMLDDA host-gc-required +safeResolvePromise diff --git a/test/built-ins/Promise/safe-resolve-promise/deferred-module-namespace.js b/test/built-ins/Promise/safe-resolve-promise/deferred-module-namespace.js new file mode 100644 index 00000000000..65f8fb32034 --- /dev/null +++ b/test/built-ins/Promise/safe-resolve-promise/deferred-module-namespace.js @@ -0,0 +1,63 @@ +// Copyright (C) 2026 Mozilla Corporation. All rights reserved. +// This code is governed by the BSD license found in the LICENSE file. + +/*--- +esid: sec-has-property-which-could-run-user-code +description: > + SafePromiseResolve defers resolution when the resolution is a module + namespace exotic object. +info: | + PropertyAccessCouldRunUserCode ( o, propertyKey, kind ) + + 3. If _o_ has the [[GetPrototypeOf]] and [[GetOwnProperty]] internal methods + as defined in Module Namespace Exotic Objects, return *true*. +includes: [asyncHelpers.js, compareArray.js] +flags: [module, async] +features: [thenable-curtailment, safeResolvePromise, promise-with-resolvers] +---*/ + +import * as ns from "./deferred-module-namespace_FIXTURE.js"; + +var expected = [ + // Being a module namespace object forces deferral without any lookup. + "start", + + "tick 1", + "tick 2", + + // No callable "then" is found, so the namespace object fulfills the promise + // from the deferred job, one microtask behind a synchronous resolution. + "settled", +]; + +var actual = []; + +asyncTest(function() { + var ruler = Promise.resolve(0) + .then(() => actual.push("tick 1")) + .then(() => actual.push("tick 2")) + .then(() => { + assert.compareArray( + actual, + expected, + "Ticks for a module namespace object" + ); + }); + + assert.sameValue(ns.then, undefined, "the fixture module does not export \"then\""); + + var capability = Promise.withResolvers(); + $262.safeResolvePromise(capability.promise, ns); + actual.push("start"); + + var settled = capability.promise.then(function(settledValue) { + actual.push("settled"); + assert.sameValue( + settledValue, + ns, + "promise is fulfilled with the module namespace object itself" + ); + }); + + return Promise.all([ruler, settled]); +}); diff --git a/test/built-ins/Promise/safe-resolve-promise/deferred-module-namespace_FIXTURE.js b/test/built-ins/Promise/safe-resolve-promise/deferred-module-namespace_FIXTURE.js new file mode 100644 index 00000000000..41334dc4bae --- /dev/null +++ b/test/built-ins/Promise/safe-resolve-promise/deferred-module-namespace_FIXTURE.js @@ -0,0 +1,4 @@ +// Copyright (C) 2026 Mozilla Corporation. All rights reserved. +// This code is governed by the BSD license found in the LICENSE file. + +export var x = 1; diff --git a/test/built-ins/Promise/safe-resolve-promise/deferred-native-promise-pending-ticks.js b/test/built-ins/Promise/safe-resolve-promise/deferred-native-promise-pending-ticks.js new file mode 100644 index 00000000000..44fc75bf980 --- /dev/null +++ b/test/built-ins/Promise/safe-resolve-promise/deferred-native-promise-pending-ticks.js @@ -0,0 +1,72 @@ +// Copyright (C) 2026 Mozilla Corporation. All rights reserved. +// This code is governed by the BSD license found in the LICENSE file. + +/*--- +esid: sec-perform-promise-resolution +description: > + Resolving with a still-pending native promise via SafePromiseResolve takes + the same number of microtasks as an ordinary resolution. +info: | + PerformPromiseResolution ( promise, resolution, thenCallTiming ) + + 9. If _thenCallTiming_ is ~deferred~, then + a. Perform ! PerformPromiseResolveThenable(_promise_, _resolution_, + _thenAction_). + b. Return ~unused~. +includes: [asyncHelpers.js, compareArray.js] +flags: [async] +features: [thenable-curtailment, safeResolvePromise, promise-with-resolvers] +---*/ + +// As for an already-fulfilled inner promise, the two paths settle in the same +// microtask, shown by the two settlements being adjacent and in the order their +// reactions were attached. +var expected = [ + "start", + + "tick 1", + "tick 2", + "tick 3", + + // Were the safe path to take an extra microtask, these two would swap. + "settled safe", + "settled ordinary", +]; + +var actual = []; + +asyncTest(function() { + var ruler = Promise.resolve(0) + .then(() => actual.push("tick 1")) + .then(() => actual.push("tick 2")) + .then(() => actual.push("tick 3")) + .then(() => { + assert.compareArray( + actual, + expected, + "Ticks for resolving with a pending promise" + ); + }); + + var safeInner = Promise.withResolvers(); + var safe = Promise.withResolvers(); + $262.safeResolvePromise(safe.promise, safeInner.promise); + var safeSettled = safe.promise.then(function(settledValue) { + actual.push("settled safe"); + assert.sameValue(settledValue, "inner", "fulfilled with the inner promise's value"); + }); + safeInner.resolve("inner"); + + var ordinaryInner = Promise.withResolvers(); + var ordinary = Promise.withResolvers(); + ordinary.resolve(ordinaryInner.promise); + var ordinarySettled = ordinary.promise.then(function(settledValue) { + actual.push("settled ordinary"); + assert.sameValue(settledValue, "inner", "fulfilled with the inner promise's value"); + }); + ordinaryInner.resolve("inner"); + + actual.push("start"); + + return Promise.all([ruler, safeSettled, ordinarySettled]); +}); diff --git a/test/built-ins/Promise/safe-resolve-promise/deferred-native-promise-ticks.js b/test/built-ins/Promise/safe-resolve-promise/deferred-native-promise-ticks.js new file mode 100644 index 00000000000..9425541b6e6 --- /dev/null +++ b/test/built-ins/Promise/safe-resolve-promise/deferred-native-promise-ticks.js @@ -0,0 +1,79 @@ +// Copyright (C) 2026 Mozilla Corporation. All rights reserved. +// This code is governed by the BSD license found in the LICENSE file. + +/*--- +esid: sec-perform-promise-resolution +description: > + Resolving with an already-fulfilled native promise via SafePromiseResolve + takes the same number of microtasks as an ordinary resolution. +info: | + PerformPromiseResolution ( promise, resolution, thenCallTiming ) + + 9. If _thenCallTiming_ is ~deferred~, then + a. Perform ! PerformPromiseResolveThenable(_promise_, _resolution_, + _thenAction_). + b. Return ~unused~. + 10. Let _thenJobCallback_ be HostMakeJobCallback(_thenAction_). + 11. Let _job_ be NewPromiseResolveThenableJob(_promise_, _resolution_, + _thenJobCallback_). + 12. Perform HostEnqueuePromiseJob(_job_.[[Job]], _job_.[[Realm]]). +includes: [asyncHelpers.js, compareArray.js] +flags: [async] +features: [thenable-curtailment, safeResolvePromise, promise-with-resolvers] +---*/ + +// A native promise has a callable "then" reachable without running user code, so +// an ordinary resolution already enqueues a job for it. In the deferred job +// _thenCallTiming_ is ~deferred~, so the "then" call is performed inline rather +// than in a further job, and the two paths settle in the same microtask. +// +// The tick each settlement lands on is fully determined by the spec given this +// test's construction, but it is not a count of the resolution's own microtasks: +// it also reflects the interleaving with the ruler. What carries the claim here +// is that the two settlements are adjacent, in the order their reactions were +// attached. +var expected = [ + "start", + + "tick 1", + "tick 2", + "tick 3", + + // Were the safe path to take an extra microtask, these two would swap. + "settled safe", + "settled ordinary", +]; + +var actual = []; + +asyncTest(function() { + var ruler = Promise.resolve(0) + .then(() => actual.push("tick 1")) + .then(() => actual.push("tick 2")) + .then(() => actual.push("tick 3")) + .then(() => { + assert.compareArray( + actual, + expected, + "Ticks for resolving with an already-fulfilled promise" + ); + }); + + var safe = Promise.withResolvers(); + $262.safeResolvePromise(safe.promise, Promise.resolve("inner")); + var safeSettled = safe.promise.then(function(settledValue) { + actual.push("settled safe"); + assert.sameValue(settledValue, "inner", "fulfilled with the inner promise's value"); + }); + + var ordinary = Promise.withResolvers(); + ordinary.resolve(Promise.resolve("inner")); + var ordinarySettled = ordinary.promise.then(function(settledValue) { + actual.push("settled ordinary"); + assert.sameValue(settledValue, "inner", "fulfilled with the inner promise's value"); + }); + + actual.push("start"); + + return Promise.all([ruler, safeSettled, ordinarySettled]); +}); diff --git a/test/built-ins/Promise/safe-resolve-promise/deferred-object-prototype-then.js b/test/built-ins/Promise/safe-resolve-promise/deferred-object-prototype-then.js new file mode 100644 index 00000000000..de4ae48b5d9 --- /dev/null +++ b/test/built-ins/Promise/safe-resolve-promise/deferred-object-prototype-then.js @@ -0,0 +1,86 @@ +// Copyright (C) 2026 Mozilla Corporation. All rights reserved. +// This code is governed by the BSD license found in the LICENSE file. + +/*--- +esid: sec-safe-promise-resolve +description: > + A callable "then" on Object.prototype defers resolution of an ordinary + object. +info: | + PropertyAccessCouldRunUserCode ( o, propertyKey, kind ) + + 7. Let _proto_ be _o_.[[GetPrototypeOf]](). + 8. If _proto_ is *null*, return *false*. + 9. Return PropertyAccessCouldRunUserCode(_proto_, _propertyKey_, _kind_). +includes: [asyncHelpers.js, compareArray.js] +flags: [async] +features: [thenable-curtailment, safeResolvePromise, promise-with-resolvers] +---*/ + +var expected = [ + // Nothing has run yet: the inherited "then" was not called synchronously. + "start", + + "tick 1", + + // The deferred job calls the inherited "then". + "call Object.prototype.then", + + "tick 2", + + // Resolved from the deferred job, so its reaction runs after it. + "settled", +]; + +var actual = []; + +var thenCalledWith; +var value = {}; + +Object.defineProperty(Object.prototype, "then", { + value: function(resolve) { + actual.push("call Object.prototype.then"); + thenCalledWith = this; + resolve("from Object.prototype"); + }, + writable: true, + enumerable: false, + configurable: true, +}); + +asyncTest(function() { + var ruler = Promise.resolve(0) + .then(() => actual.push("tick 1")) + .then(() => actual.push("tick 2")) + .then(() => { + try { + assert.compareArray( + actual, + expected, + "Ticks for a callable Object.prototype.then" + ); + assert.sameValue( + thenCalledWith, + value, + "\"then\" is called with the resolution as its this value" + ); + } finally { + delete Object.prototype.then; + } + }); + + var capability = Promise.withResolvers(); + $262.safeResolvePromise(capability.promise, value); + actual.push("start"); + + var settled = capability.promise.then(function(settledValue) { + actual.push("settled"); + assert.sameValue( + settledValue, + "from Object.prototype", + "the inherited \"then\" resolves the promise" + ); + }); + + return Promise.all([ruler, settled]); +}); diff --git a/test/built-ins/Promise/safe-resolve-promise/deferred-proxy-empty-handler.js b/test/built-ins/Promise/safe-resolve-promise/deferred-proxy-empty-handler.js new file mode 100644 index 00000000000..60b5d0b44da --- /dev/null +++ b/test/built-ins/Promise/safe-resolve-promise/deferred-proxy-empty-handler.js @@ -0,0 +1,74 @@ +// Copyright (C) 2026 Mozilla Corporation. All rights reserved. +// This code is governed by the BSD license found in the LICENSE file. + +/*--- +esid: sec-has-property-which-could-run-user-code +description: > + SafePromiseResolve defers resolution when the resolution is a Proxy exotic + object, even when its handler defines no traps. +info: | + PropertyAccessCouldRunUserCode ( o, propertyKey, kind ) + + 2. If _o_ has the [[GetPrototypeOf]] and [[GetOwnProperty]] internal methods + as defined in Proxy Object Internal Methods and Internal Slots, return + *true*. +includes: [asyncHelpers.js, compareArray.js] +flags: [async] +features: [thenable-curtailment, safeResolvePromise, promise-with-resolvers, Proxy] +---*/ + +var expected = [ + "start", + + "tick 1", + "tick 2", + + // Every resolution was deferred to a job, so each settlement lands after + // "tick 2" rather than before it, whatever the target's own shape was. + "settled plain object", + "settled non-callable then", + "settled array", + "settled function", +]; + +// The target's own shape must not matter: deferral is forced by the Proxy. +var targets = [ + ["plain object", {}], + ["non-callable then", { then: 42 }], + ["array", []], + ["function", function() {}], +]; + +var actual = []; + +asyncTest(function() { + var ruler = Promise.resolve(0) + .then(() => actual.push("tick 1")) + .then(() => actual.push("tick 2")) + .then(() => { + assert.compareArray(actual, expected, "Ticks for Proxy resolutions"); + }); + + var checks = []; + + targets.forEach(function(entry) { + var label = entry[0]; + var value = new Proxy(entry[1], {}); + + var capability = Promise.withResolvers(); + $262.safeResolvePromise(capability.promise, value); + + checks.push(capability.promise.then(function(settledValue) { + actual.push("settled " + label); + assert.sameValue( + settledValue, + value, + "promise is fulfilled with the Proxy itself: " + label + ); + })); + }); + + actual.push("start"); + + return Promise.all([ruler].concat(checks)); +}); diff --git a/test/built-ins/Promise/safe-resolve-promise/deferred-proxy-get-trap.js b/test/built-ins/Promise/safe-resolve-promise/deferred-proxy-get-trap.js new file mode 100644 index 00000000000..b88ee328db4 --- /dev/null +++ b/test/built-ins/Promise/safe-resolve-promise/deferred-proxy-get-trap.js @@ -0,0 +1,81 @@ +// Copyright (C) 2026 Mozilla Corporation. All rights reserved. +// This code is governed by the BSD license found in the LICENSE file. + +/*--- +esid: sec-safe-promise-resolve +description: > + A Proxy "get" trap returning a callable "then" is invoked from the deferred + job, not from SafePromiseResolve itself. +info: | + SafePromiseResolve ( promiseCapability, resolution ) + + 3. Let _deferredSteps_ be a new Abstract Closure that captures _promise_ and + _resolution_ and performs the following steps when called: + a. Perform ? PerformPromiseResolution(_promise_, _resolution_, ~deferred~). + + PerformPromiseResolution ( promise, resolution, thenCallTiming ) + + 4. Let _then_ be Completion(Get(_resolution_, *"then"*)). + ... + 9. If _thenCallTiming_ is ~deferred~, then + a. Perform ! PerformPromiseResolveThenable(_promise_, _resolution_, + _thenAction_). +includes: [asyncHelpers.js, compareArray.js] +flags: [async] +features: [thenable-curtailment, safeResolvePromise, promise-with-resolvers, Proxy] +---*/ + +var expected = [ + // Being a Proxy forces deferral before any trap can run. + "start", + + "tick 1", + + // The deferred job reads "then" once and calls what the trap returned. + "get:then", + "call then", + + "tick 2", + "settled", +]; + +var actual = []; + +// "then" is the only property read from the resolution, so any other lookup +// shows up in the comparison below as an unexpected entry. +var value = new Proxy({}, { + get: function(target, key) { + actual.push("get:" + String(key)); + if (key !== "then") { + return undefined; + } + return function(resolve) { + actual.push("call then"); + resolve("from the trap"); + }; + }, +}); + +asyncTest(function() { + var ruler = Promise.resolve(0) + .then(() => actual.push("tick 1")) + .then(() => actual.push("tick 2")) + .then(() => { + assert.compareArray(actual, expected, "Ticks for a Proxy \"get\" trap"); + }); + + var capability = Promise.withResolvers(); + $262.safeResolvePromise(capability.promise, value); + actual.push("start"); + + var settled = capability.promise.then(function(settledValue) { + actual.push("settled"); + assert.sameValue( + settledValue, + "from the trap", + "promise is fulfilled with the value passed to the resolving function" + ); + }); + + return Promise.all([ruler, settled]); +}); diff --git a/test/built-ins/Promise/safe-resolve-promise/deferred-proxy-on-prototype-chain.js b/test/built-ins/Promise/safe-resolve-promise/deferred-proxy-on-prototype-chain.js new file mode 100644 index 00000000000..49b96c3b939 --- /dev/null +++ b/test/built-ins/Promise/safe-resolve-promise/deferred-proxy-on-prototype-chain.js @@ -0,0 +1,93 @@ +// Copyright (C) 2026 Mozilla Corporation. All rights reserved. +// This code is governed by the BSD license found in the LICENSE file. + +/*--- +esid: sec-has-property-which-could-run-user-code +description: > + SafePromiseResolve defers resolution when an ordinary object has a Proxy on + its prototype chain. +info: | + PropertyAccessCouldRunUserCode ( o, propertyKey, kind ) + + 2. If _o_ has the [[GetPrototypeOf]] and [[GetOwnProperty]] internal methods + as defined in Proxy Object Internal Methods and Internal Slots, return + *true*. + ... + 7. Let _proto_ be _o_.[[GetPrototypeOf]](). + 8. If _proto_ is *null*, return *false*. + 9. Return PropertyAccessCouldRunUserCode(_proto_, _propertyKey_, _kind_). +includes: [asyncHelpers.js, compareArray.js] +flags: [async] +features: [thenable-curtailment, safeResolvePromise, promise-with-resolvers, Proxy] +---*/ + +var actual = []; + +// The Proxy is the immediate prototype, and further up the chain behind +// ordinary objects. +function makeValue(label, depth) { + var proxyProto = new Proxy({}, { + get: function(target, key, receiver) { + actual.push("get:" + String(key) + " " + label); + return Reflect.get(target, key, receiver); + }, + }); + + var value = proxyProto; + for (var i = 0; i < depth; i += 1) { + value = Object.create(value); + } + return value; +} + +var expected = [ + "start", + + "tick 1", + + // Each deferred job performs its own "then" lookup. + "get:then immediate", + "get:then deep", + + "tick 2", + + // No callable "then" is found, so each promise fulfills with the resolution. + "settled immediate", + "settled deep", +]; + +asyncTest(function() { + var ruler = Promise.resolve(0) + .then(() => actual.push("tick 1")) + .then(() => actual.push("tick 2")) + .then(() => { + assert.compareArray( + actual, + expected, + "Ticks for a Proxy on the prototype chain" + ); + }); + + var checks = []; + + [["immediate", 1], ["deep", 3]].forEach(function(entry) { + var label = entry[0]; + var value = makeValue(label, entry[1]); + + var capability = Promise.withResolvers(); + $262.safeResolvePromise(capability.promise, value); + + checks.push(capability.promise.then(function(settledValue) { + actual.push("settled " + label); + assert.sameValue( + settledValue, + value, + "promise is fulfilled with the resolution itself: " + label + ); + })); + }); + + actual.push("start"); + + return Promise.all([ruler].concat(checks)); +}); diff --git a/test/built-ins/Promise/safe-resolve-promise/deferred-proxy-revoked.js b/test/built-ins/Promise/safe-resolve-promise/deferred-proxy-revoked.js new file mode 100644 index 00000000000..9cbc6cd5923 --- /dev/null +++ b/test/built-ins/Promise/safe-resolve-promise/deferred-proxy-revoked.js @@ -0,0 +1,70 @@ +// Copyright (C) 2026 Mozilla Corporation. All rights reserved. +// This code is governed by the BSD license found in the LICENSE file. + +/*--- +esid: sec-perform-promise-resolution +description: > + Resolving with a revoked Proxy rejects the promise from the deferred job, + without SafePromiseResolve throwing. +info: | + PropertyAccessCouldRunUserCode ( o, propertyKey, kind ) + + 2. If _o_ has the [[GetPrototypeOf]] and [[GetOwnProperty]] internal methods + as defined in Proxy Object Internal Methods and Internal Slots, return + *true*. + + PerformPromiseResolution ( promise, resolution, thenCallTiming ) + + 4. Let _then_ be Completion(Get(_resolution_, *"then"*)). + 5. If _then_ is an abrupt completion, then + a. Perform RejectPromise(_promise_, _then_.[[Value]]). +includes: [asyncHelpers.js, compareArray.js] +flags: [async] +features: [thenable-curtailment, safeResolvePromise, promise-with-resolvers, Proxy] +---*/ + +var expected = [ + // No lookup is attempted, so the revoked Proxy cannot throw here. + "start", + + "tick 1", + "tick 2", + + // The lookup in the deferred job throws, rejecting the promise. + "rejected", +]; + +var actual = []; + +var revocable = Proxy.revocable({}, {}); +revocable.revoke(); + +asyncTest(function() { + var ruler = Promise.resolve(0) + .then(() => actual.push("tick 1")) + .then(() => actual.push("tick 2")) + .then(() => { + assert.compareArray(actual, expected, "Ticks for a revoked Proxy"); + }); + + var capability = Promise.withResolvers(); + + $262.safeResolvePromise(capability.promise, revocable.proxy); + actual.push("start"); + + var settled = capability.promise.then( + function() { + actual.push("fulfilled"); + }, + function(reason) { + actual.push("rejected"); + assert.sameValue( + reason instanceof TypeError, + true, + "the promise is rejected with the TypeError thrown by the revoked Proxy" + ); + } + ); + + return Promise.all([ruler, settled]); +}); diff --git a/test/built-ins/Promise/safe-resolve-promise/deferred-self-resolution.js b/test/built-ins/Promise/safe-resolve-promise/deferred-self-resolution.js new file mode 100644 index 00000000000..e5eee45d19c --- /dev/null +++ b/test/built-ins/Promise/safe-resolve-promise/deferred-self-resolution.js @@ -0,0 +1,74 @@ +// Copyright (C) 2026 Mozilla Corporation. All rights reserved. +// This code is governed by the BSD license found in the LICENSE file. + +/*--- +esid: sec-perform-promise-resolution +description: > + Resolving a promise with itself through SafePromiseResolve rejects it with a + TypeError from the deferred job. +info: | + PerformPromiseResolution ( promise, resolution, thenCallTiming ) + + 2. If SameValue(_resolution_, _promise_) is *true*, then + a. Let _selfResolutionError_ be a newly created *TypeError* object. + b. Perform RejectPromise(_promise_, _selfResolutionError_). + c. Return ~unused~. + + RequiresDeferredPromiseResolution ( value ) + + 3. Let _thenValue_ be ! _value_.[[Get]](*"then"*). + 4. If IsCallable(_thenValue_) is *true*, return *true*. +includes: [asyncHelpers.js, compareArray.js] +flags: [async] +features: [thenable-curtailment, safeResolvePromise, promise-with-resolvers] +---*/ + +// SafePromiseResolve does not special-case a resolution which is the promise +// itself: the promise inherits a callable "then" from Promise.prototype, so +// RequiresDeferredPromiseResolution reports true and resolution is deferred like +// any other thenable. The self-resolution check then runs in the deferred job. +// See sync-self-resolution.js for the same error reported synchronously, which +// is what happens when the promise does not look thenable. +var expected = [ + // No check has run yet, so SafePromiseResolve reports nothing. + "start", + + "tick 1", + "tick 2", + + // The deferred job finds SameValue(resolution, promise) and rejects. + "rejected", +]; + +var actual = []; + +asyncTest(function() { + var ruler = Promise.resolve(0) + .then(() => actual.push("tick 1")) + .then(() => actual.push("tick 2")) + .then(() => { + assert.compareArray(actual, expected, "Ticks for a deferred self resolution"); + }); + + var capability = Promise.withResolvers(); + + // SafePromiseResolve must not throw. + $262.safeResolvePromise(capability.promise, capability.promise); + actual.push("start"); + + var settled = capability.promise.then( + function(settledValue) { + throw new Test262Error("the promise must not be fulfilled: " + settledValue); + }, + function(reason) { + actual.push("rejected"); + assert.sameValue( + reason instanceof TypeError, + true, + "the promise is rejected with a TypeError" + ); + } + ); + + return Promise.all([ruler, settled]); +}); diff --git a/test/built-ins/Promise/safe-resolve-promise/deferred-then-call-throws.js b/test/built-ins/Promise/safe-resolve-promise/deferred-then-call-throws.js new file mode 100644 index 00000000000..0da6cb220e6 --- /dev/null +++ b/test/built-ins/Promise/safe-resolve-promise/deferred-then-call-throws.js @@ -0,0 +1,72 @@ +// Copyright (C) 2026 Mozilla Corporation. All rights reserved. +// This code is governed by the BSD license found in the LICENSE file. + +/*--- +esid: sec-perform-promise-resolve-thenable +description: > + A "then" method that throws when called rejects the promise from the + deferred job, without SafePromiseResolve throwing. +info: | + PerformPromiseResolveThenable ( promiseToResolve, thenable, then ) + + 2. If _then_ is a function object, then + a. Let _thenCallResult_ be Completion(Call(_then_, _thenable_, + « _resolvingFunctions_.[[Resolve]], + _resolvingFunctions_.[[Reject]] »)). + ... + 4. If _thenCallResult_ is an abrupt completion, then + a. Return ! Call(_resolvingFunctions_.[[Reject]], *undefined*, + « _thenCallResult_.[[Value]] »). +includes: [asyncHelpers.js, compareArray.js] +flags: [async] +features: [thenable-curtailment, safeResolvePromise, promise-with-resolvers] +---*/ + +var expected = [ + // "then" has not been called, so nothing can have thrown yet. + "start", + + "tick 1", + + // The deferred job calls "then", which throws. + "call then", + + "tick 2", + "rejected", +]; + +var actual = []; + +var sentinel = new Error("thrown by \"then\""); +var value = { + then: function() { + actual.push("call then"); + throw sentinel; + }, +}; + +asyncTest(function() { + var ruler = Promise.resolve(0) + .then(() => actual.push("tick 1")) + .then(() => actual.push("tick 2")) + .then(() => { + assert.compareArray(actual, expected, "Ticks for a throwing \"then\" call"); + }); + + var capability = Promise.withResolvers(); + + $262.safeResolvePromise(capability.promise, value); + actual.push("start"); + + var settled = capability.promise.then( + function() { + actual.push("fulfilled"); + }, + function(reason) { + actual.push("rejected"); + assert.sameValue(reason, sentinel, "the promise is rejected with the thrown value"); + } + ); + + return Promise.all([ruler, settled]); +}); diff --git a/test/built-ins/Promise/safe-resolve-promise/deferred-then-callable-own.js b/test/built-ins/Promise/safe-resolve-promise/deferred-then-callable-own.js new file mode 100644 index 00000000000..520ab3cd73b --- /dev/null +++ b/test/built-ins/Promise/safe-resolve-promise/deferred-then-callable-own.js @@ -0,0 +1,62 @@ +// Copyright (C) 2026 Mozilla Corporation. All rights reserved. +// This code is governed by the BSD license found in the LICENSE file. + +/*--- +esid: sec-safe-promise-resolve +description: > + SafePromiseResolve defers the call to an own callable "then" data property. +info: | + RequiresDeferredPromiseResolution ( value ) + + 3. Let _thenValue_ be ! _value_.[[Get]](*"then"*). + 4. If IsCallable(_thenValue_) is *true*, return *true*. +includes: [asyncHelpers.js, compareArray.js] +flags: [async] +features: [thenable-curtailment, safeResolvePromise, promise-with-resolvers] +---*/ + +var expected = [ + // SafePromiseResolve returns without calling "then". + "start", + + "tick 1", + + // The deferred job calls "then". + "call then", + + "tick 2", + "settled", +]; + +var actual = []; + +var value = { + then: function(resolve) { + actual.push("call then"); + resolve("from then"); + }, +}; + +asyncTest(function() { + var ruler = Promise.resolve(0) + .then(() => actual.push("tick 1")) + .then(() => actual.push("tick 2")) + .then(() => { + assert.compareArray(actual, expected, "Ticks for a deferred callable \"then\""); + }); + + var capability = Promise.withResolvers(); + $262.safeResolvePromise(capability.promise, value); + actual.push("start"); + + var settled = capability.promise.then(function(settledValue) { + actual.push("settled"); + assert.sameValue( + settledValue, + "from then", + "promise is fulfilled with the value passed to the resolving function" + ); + }); + + return Promise.all([ruler, settled]); +}); diff --git a/test/built-ins/Promise/safe-resolve-promise/deferred-then-callable-proto.js b/test/built-ins/Promise/safe-resolve-promise/deferred-then-callable-proto.js new file mode 100644 index 00000000000..14f4be03371 --- /dev/null +++ b/test/built-ins/Promise/safe-resolve-promise/deferred-then-callable-proto.js @@ -0,0 +1,90 @@ +// Copyright (C) 2026 Mozilla Corporation. All rights reserved. +// This code is governed by the BSD license found in the LICENSE file. + +/*--- +esid: sec-safe-promise-resolve +description: > + SafePromiseResolve defers the call to a callable "then" inherited from the + prototype chain. +info: | + PropertyAccessCouldRunUserCode ( o, propertyKey, kind ) + + 7. Let _proto_ be _o_.[[GetPrototypeOf]](). + 8. If _proto_ is *null*, return *false*. + 9. Return PropertyAccessCouldRunUserCode(_proto_, _propertyKey_, _kind_). + + RequiresDeferredPromiseResolution ( value ) + + 3. Let _thenValue_ be ! _value_.[[Get]](*"then"*). + 4. If IsCallable(_thenValue_) is *true*, return *true*. +includes: [asyncHelpers.js, compareArray.js] +flags: [async] +features: [thenable-curtailment, safeResolvePromise, promise-with-resolvers] +---*/ + +var actual = []; + +var proto = { + then: function(resolve) { + actual.push("call then " + this.label); + resolve("from the prototype: " + this.label); + }, +}; + +var expected = [ + "start", + + "tick 1", + + // Each deferred job calls the inherited "then". + "call then immediate", + "call then deep", + + "tick 2", + + // Each promise was resolved during its job, so its reaction runs after it. + "settled immediate", + "settled deep", +]; + +asyncTest(function() { + var ruler = Promise.resolve(0) + .then(() => actual.push("tick 1")) + .then(() => actual.push("tick 2")) + .then(() => { + assert.compareArray( + actual, + expected, + "Ticks for a deferred inherited callable \"then\"" + ); + }); + + // Directly inherited, and inherited from further up the chain. + var checks = []; + + [["immediate", 1], ["deep", 3]].forEach(function(entry) { + var label = entry[0]; + + var value = proto; + for (var i = 0; i < entry[1]; i += 1) { + value = Object.create(value); + } + value.label = label; + + var capability = Promise.withResolvers(); + $262.safeResolvePromise(capability.promise, value); + + checks.push(capability.promise.then(function(settledValue) { + actual.push("settled " + label); + assert.sameValue( + settledValue, + "from the prototype: " + label, + "promise is fulfilled with the value passed to the resolving function: " + label + ); + })); + }); + + actual.push("start"); + + return Promise.all([ruler].concat(checks)); +}); diff --git a/test/built-ins/Promise/safe-resolve-promise/deferred-then-deleted-before-job.js b/test/built-ins/Promise/safe-resolve-promise/deferred-then-deleted-before-job.js new file mode 100644 index 00000000000..1b2b058f56e --- /dev/null +++ b/test/built-ins/Promise/safe-resolve-promise/deferred-then-deleted-before-job.js @@ -0,0 +1,80 @@ +// Copyright (C) 2026 Mozilla Corporation. All rights reserved. +// This code is governed by the BSD license found in the LICENSE file. + +/*--- +esid: sec-perform-promise-resolution +description: > + Deleting the callable "then" after SafePromiseResolve returns, but before + the deferred job runs, fulfills the promise with the resolution itself. +info: | + PerformPromiseResolution ( promise, resolution, thenCallTiming ) + + 4. Let _then_ be Completion(Get(_resolution_, *"then"*)). + ... + 8. If IsCallable(_thenAction_) is *false*, then + a. Perform FulfillPromise(_promise_, _resolution_). + b. Return ~unused~. +includes: [asyncHelpers.js, compareArray.js] +flags: [async] +features: [thenable-curtailment, safeResolvePromise, promise-with-resolvers] +---*/ + +var expected = [ + // Deferred, because "then" was callable at the time of the call. + "start", + + // Still within the deferral window, so this is observable by the job. + "deleted then", + + "tick 1", + "tick 2", + + // No callable "then" remains, so "call then" never appears and the promise + // is fulfilled with the resolution itself. + "settled", +]; + +var actual = []; + +var value = { + then: function(resolve) { + actual.push("call then"); + resolve("must not be observed"); + }, +}; + +asyncTest(function() { + var ruler = Promise.resolve(0) + .then(() => actual.push("tick 1")) + .then(() => actual.push("tick 2")) + .then(() => { + assert.compareArray( + actual, + expected, + "Ticks for a \"then\" deleted during the deferral window" + ); + }); + + var capability = Promise.withResolvers(); + $262.safeResolvePromise(capability.promise, value); + actual.push("start"); + + delete value.then; + actual.push("deleted then"); + + var settled = capability.promise.then( + function(settledValue) { + actual.push("settled"); + assert.sameValue( + settledValue, + value, + "promise is fulfilled with the resolution itself, as for any non-thenable" + ); + }, + function() { + actual.push("rejected"); + } + ); + + return Promise.all([ruler, settled]); +}); diff --git a/test/built-ins/Promise/safe-resolve-promise/deferred-then-getter-own.js b/test/built-ins/Promise/safe-resolve-promise/deferred-then-getter-own.js new file mode 100644 index 00000000000..2c000713011 --- /dev/null +++ b/test/built-ins/Promise/safe-resolve-promise/deferred-then-getter-own.js @@ -0,0 +1,69 @@ +// Copyright (C) 2026 Mozilla Corporation. All rights reserved. +// This code is governed by the BSD license found in the LICENSE file. + +/*--- +esid: sec-has-property-which-could-run-user-code +description: > + SafePromiseResolve defers resolution when the resolution has an own "then" + getter, and the getter runs in the deferred job. +info: | + PropertyAccessCouldRunUserCode ( o, propertyKey, kind ) + + 6. If _desc_ is not *undefined*, then + a. If IsAccessorDescriptor(_desc_) is *true*, then + i. If _kind_ is either ~any~ or ~get~, and _desc_.[[Get]] is not + *undefined*, return *true*. +includes: [asyncHelpers.js, compareArray.js] +flags: [async] +features: [thenable-curtailment, safeResolvePromise, promise-with-resolvers] +---*/ + +var expected = [ + // SafePromiseResolve returns without reading "then". + "start", + + "tick 1", + + // The deferred job reads "then" and calls it. + "get then", + "call then", + + "tick 2", + "settled", +]; + +var actual = []; + +var value = { + get then() { + actual.push("get then"); + return function(resolve) { + actual.push("call then"); + resolve("from the getter"); + }; + }, +}; + +asyncTest(function() { + var ruler = Promise.resolve(0) + .then(() => actual.push("tick 1")) + .then(() => actual.push("tick 2")) + .then(() => { + assert.compareArray(actual, expected, "Ticks for a deferred \"then\" getter"); + }); + + var capability = Promise.withResolvers(); + $262.safeResolvePromise(capability.promise, value); + actual.push("start"); + + var settled = capability.promise.then(function(settledValue) { + actual.push("settled"); + assert.sameValue( + settledValue, + "from the getter", + "promise is fulfilled with the value passed to the resolving function" + ); + }); + + return Promise.all([ruler, settled]); +}); diff --git a/test/built-ins/Promise/safe-resolve-promise/deferred-then-getter-proto.js b/test/built-ins/Promise/safe-resolve-promise/deferred-then-getter-proto.js new file mode 100644 index 00000000000..f762df6dce9 --- /dev/null +++ b/test/built-ins/Promise/safe-resolve-promise/deferred-then-getter-proto.js @@ -0,0 +1,84 @@ +// Copyright (C) 2026 Mozilla Corporation. All rights reserved. +// This code is governed by the BSD license found in the LICENSE file. + +/*--- +esid: sec-has-property-which-could-run-user-code +description: > + SafePromiseResolve defers resolution when a "then" getter is inherited from + the prototype chain, and the getter runs in the deferred job. +info: | + PropertyAccessCouldRunUserCode ( o, propertyKey, kind ) + + 6. If _desc_ is not *undefined*, then + a. If IsAccessorDescriptor(_desc_) is *true*, then + i. If _kind_ is either ~any~ or ~get~, and _desc_.[[Get]] is not + *undefined*, return *true*. + 7. Let _proto_ be _o_.[[GetPrototypeOf]](). + ... + 9. Return PropertyAccessCouldRunUserCode(_proto_, _propertyKey_, _kind_). +includes: [asyncHelpers.js, compareArray.js] +flags: [async] +features: [thenable-curtailment, safeResolvePromise, promise-with-resolvers] +---*/ + +var expected = [ + // SafePromiseResolve returns without reading "then". + "start", + + "tick 1", + + // The deferred job reads "then" and calls it. + "get then", + "call then", + + "tick 2", + "settled", +]; + +var actual = []; + +var value; +var proto = { + get then() { + actual.push("get then"); + assert.sameValue( + this, + value, + "the getter receives the resolution, not the prototype, as its receiver" + ); + return function(resolve) { + actual.push("call then"); + resolve("from the inherited getter"); + }; + }, +}; + +value = Object.create(proto); + +asyncTest(function() { + var ruler = Promise.resolve(0) + .then(() => actual.push("tick 1")) + .then(() => actual.push("tick 2")) + .then(() => { + assert.compareArray( + actual, + expected, + "Ticks for a deferred inherited \"then\" getter" + ); + }); + + var capability = Promise.withResolvers(); + $262.safeResolvePromise(capability.promise, value); + actual.push("start"); + + var settled = capability.promise.then(function(settledValue) { + actual.push("settled"); + assert.sameValue( + settledValue, + "from the inherited getter", + "promise is fulfilled with the value passed to the resolving function" + ); + }); + + return Promise.all([ruler, settled]); +}); diff --git a/test/built-ins/Promise/safe-resolve-promise/deferred-then-getter-throws.js b/test/built-ins/Promise/safe-resolve-promise/deferred-then-getter-throws.js new file mode 100644 index 00000000000..6c9dc25694e --- /dev/null +++ b/test/built-ins/Promise/safe-resolve-promise/deferred-then-getter-throws.js @@ -0,0 +1,68 @@ +// Copyright (C) 2026 Mozilla Corporation. All rights reserved. +// This code is governed by the BSD license found in the LICENSE file. + +/*--- +esid: sec-perform-promise-resolution +description: > + A throwing "then" getter rejects the promise from the deferred job, without + SafePromiseResolve throwing. +info: | + PerformPromiseResolution ( promise, resolution, thenCallTiming ) + + 4. Let _then_ be Completion(Get(_resolution_, *"then"*)). + 5. If _then_ is an abrupt completion, then + a. Perform RejectPromise(_promise_, _then_.[[Value]]). + b. Return ~unused~. +includes: [asyncHelpers.js, compareArray.js] +flags: [async] +features: [thenable-curtailment, safeResolvePromise, promise-with-resolvers] +---*/ + +var expected = [ + // The getter has not run, so nothing can have thrown yet. + "start", + + "tick 1", + + // The deferred job reads "then", and the getter throws. + "get then", + + "tick 2", + "rejected", +]; + +var actual = []; + +var sentinel = new Error("thrown by the \"then\" getter"); +var value = { + get then() { + actual.push("get then"); + throw sentinel; + }, +}; + +asyncTest(function() { + var ruler = Promise.resolve(0) + .then(() => actual.push("tick 1")) + .then(() => actual.push("tick 2")) + .then(() => { + assert.compareArray(actual, expected, "Ticks for a throwing \"then\" getter"); + }); + + var capability = Promise.withResolvers(); + + $262.safeResolvePromise(capability.promise, value); + actual.push("start"); + + var settled = capability.promise.then( + function() { + actual.push("fulfilled"); + }, + function(reason) { + actual.push("rejected"); + assert.sameValue(reason, sentinel, "the promise is rejected with the thrown value"); + } + ); + + return Promise.all([ruler, settled]); +}); diff --git a/test/built-ins/Promise/safe-resolve-promise/deferred-then-reject.js b/test/built-ins/Promise/safe-resolve-promise/deferred-then-reject.js new file mode 100644 index 00000000000..42192a4f921 --- /dev/null +++ b/test/built-ins/Promise/safe-resolve-promise/deferred-then-reject.js @@ -0,0 +1,72 @@ +// Copyright (C) 2026 Mozilla Corporation. All rights reserved. +// This code is governed by the BSD license found in the LICENSE file. + +/*--- +esid: sec-perform-promise-resolve-thenable +description: > + A deferred "then" rejects the promise by calling the second resolving + function it is passed. +info: | + PerformPromiseResolveThenable ( promiseToResolve, thenable, then ) + + 1. Let _resolvingFunctions_ be CreateResolvingFunctions(_promiseToResolve_). + 2. If _then_ is a function object, then + a. Let _thenCallResult_ be Completion(Call(_then_, _thenable_, + « _resolvingFunctions_.[[Resolve]], + _resolvingFunctions_.[[Reject]] »)). +includes: [asyncHelpers.js, compareArray.js] +flags: [async] +features: [thenable-curtailment, safeResolvePromise, promise-with-resolvers] +---*/ + +var expected = [ + // "then" has not been called, so the promise cannot have been rejected yet. + "start", + + "tick 1", + + // The deferred job calls "then", which rejects through its second argument. + "call then", + + "tick 2", + "rejected", +]; + +var actual = []; + +var sentinel = new Error("passed to the reject function"); +var value = { + then: function(_resolve, reject) { + actual.push("call then"); + reject(sentinel); + }, +}; + +asyncTest(function() { + var ruler = Promise.resolve(0) + .then(() => actual.push("tick 1")) + .then(() => actual.push("tick 2")) + .then(() => { + assert.compareArray(actual, expected, "Ticks for a rejecting deferred \"then\""); + }); + + var capability = Promise.withResolvers(); + $262.safeResolvePromise(capability.promise, value); + actual.push("start"); + + var settled = capability.promise.then( + function() { + throw new Test262Error("the promise must not be fulfilled"); + }, + function(reason) { + actual.push("rejected"); + assert.sameValue( + reason, + sentinel, + "the promise is rejected with the value passed to the reject function" + ); + } + ); + + return Promise.all([ruler, settled]); +}); diff --git a/test/built-ins/Promise/safe-resolve-promise/deferred-then-this-binding.js b/test/built-ins/Promise/safe-resolve-promise/deferred-then-this-binding.js new file mode 100644 index 00000000000..22c82766949 --- /dev/null +++ b/test/built-ins/Promise/safe-resolve-promise/deferred-then-this-binding.js @@ -0,0 +1,56 @@ +// Copyright (C) 2026 Mozilla Corporation. All rights reserved. +// This code is governed by the BSD license found in the LICENSE file. + +/*--- +esid: sec-perform-promise-resolve-thenable +description: > + The deferred "then" call receives the resolution as its this value and two + distinct fresh resolving functions. +info: | + PerformPromiseResolveThenable ( promiseToResolve, thenable, then ) + + 1. Let _resolvingFunctions_ be CreateResolvingFunctions(_promiseToResolve_). + 2. If _then_ is a function object, then + a. Let _thenCallResult_ be Completion(Call(_then_, _thenable_, + « _resolvingFunctions_.[[Resolve]], + _resolvingFunctions_.[[Reject]] »)). +includes: [asyncHelpers.js] +flags: [async] +features: [thenable-curtailment, safeResolvePromise, promise-with-resolvers] +---*/ + +var observed; +var value = { + then: function(resolve, reject) { + observed = { + thisValue: this, + argCount: arguments.length, + resolve: resolve, + reject: reject, + }; + resolve("done"); + }, +}; + +asyncTest(function() { + var capability = Promise.withResolvers(); + $262.safeResolvePromise(capability.promise, value); + + return capability.promise.then(function(settledValue) { + assert.sameValue(settledValue, "done", "promise is fulfilled with the resolved value"); + + assert.sameValue( + observed.thisValue, + value, + "\"then\" is called with the thenable as its this value" + ); + assert.sameValue(observed.argCount, 2, "\"then\" is called with exactly two arguments"); + assert.sameValue(typeof observed.resolve, "function", "the first argument is a function"); + assert.sameValue(typeof observed.reject, "function", "the second argument is a function"); + assert.notSameValue( + observed.resolve, + observed.reject, + "the resolving functions are distinct" + ); + }); +}); diff --git a/test/built-ins/Promise/safe-resolve-promise/latch-after-deferred.js b/test/built-ins/Promise/safe-resolve-promise/latch-after-deferred.js new file mode 100644 index 00000000000..055d84d8a53 --- /dev/null +++ b/test/built-ins/Promise/safe-resolve-promise/latch-after-deferred.js @@ -0,0 +1,80 @@ +// Copyright (C) 2026 Mozilla Corporation. All rights reserved. +// This code is governed by the BSD license found in the LICENSE file. + +/*--- +esid: sec-safe-promise-resolve +description: > + A promise whose resolution was deferred is already latched on return from + SafePromiseResolve: later calls to its resolving functions are no-ops. +info: | + SafePromiseResolve ( promiseCapability, resolution ) + + ... + 5. Let _wrapper_ be OrdinaryObjectCreate(*null*). + 6. Perform ! CreateDataPropertyOrThrow(_wrapper_, *"then"*, _deferredThen_). + 7. Return ? Call(_promiseCapability_.[[Resolve]], *undefined*, + « _wrapper_ »). +includes: [asyncHelpers.js] +flags: [async] +features: [thenable-curtailment, safeResolvePromise, promise-with-resolvers, Proxy] +---*/ + +function mustNotReject(reason) { + throw new Test262Error("promise was rejected: " + reason); +} + +asyncTest(function() { + // A thenable resolution, raced by both resolving functions. + var thenCallCount = 0; + var raced = Promise.withResolvers(); + $262.safeResolvePromise(raced.promise, { + then: function(resolve) { + thenCallCount += 1; + resolve("from the thenable"); + }, + }); + raced.resolve("racing resolve"); + raced.reject("racing reject"); + + var racedCheck = raced.promise.then(function(settledValue) { + assert.sameValue( + settledValue, + "from the thenable", + "the deferred resolution wins over both racing calls" + ); + assert.sameValue(thenCallCount, 1, "the thenable's \"then\" still runs exactly once"); + }, mustNotReject); + + // A thenable resolution, raced by reject alone. + var rejected = Promise.withResolvers(); + $262.safeResolvePromise(rejected.promise, { + then: function(resolve) { + resolve("still fulfilled"); + }, + }); + rejected.reject(new Error("must be ignored")); + + var rejectedCheck = rejected.promise.then(function(settledValue) { + assert.sameValue( + settledValue, + "still fulfilled", + "the deferred resolution wins over the racing reject" + ); + }, mustNotReject); + + // A deferred resolution which is not a thenable at all. + var proxy = new Proxy({}, {}); + var nonThenable = Promise.withResolvers(); + $262.safeResolvePromise(nonThenable.promise, proxy); + nonThenable.resolve("racing resolve"); + + var nonThenableCheck = nonThenable.promise.then(function(settledValue) { + assert.sameValue( + settledValue, + proxy, + "the promise is fulfilled with the deferred resolution, not the racing value" + ); + }, mustNotReject); + + return Promise.all([racedCheck, rejectedCheck, nonThenableCheck]); +}); diff --git a/test/built-ins/Promise/safe-resolve-promise/latch-after-sync.js b/test/built-ins/Promise/safe-resolve-promise/latch-after-sync.js new file mode 100644 index 00000000000..a763f6b991f --- /dev/null +++ b/test/built-ins/Promise/safe-resolve-promise/latch-after-sync.js @@ -0,0 +1,41 @@ +// Copyright (C) 2026 Mozilla Corporation. All rights reserved. +// This code is governed by the BSD license found in the LICENSE file. + +/*--- +esid: sec-safe-promise-resolve +description: > + A promise resolved through the synchronous path of SafePromiseResolve is + latched: later calls to its resolving functions are no-ops. +info: | + SafePromiseResolve ( promiseCapability, resolution ) + + 1. If RequiresDeferredPromiseResolution(_resolution_) is *false*, then + a. Return ? Call(_promiseCapability_.[[Resolve]], *undefined*, + « _resolution_ »). +includes: [asyncHelpers.js] +flags: [async] +features: [thenable-curtailment, safeResolvePromise, promise-with-resolvers] +---*/ + +asyncTest(function() { + var capability = Promise.withResolvers(); + + $262.safeResolvePromise(capability.promise, "first"); + capability.resolve("racing resolve"); + capability.reject("racing reject"); + + return capability.promise.then( + function(settledValue) { + assert.sameValue( + settledValue, + "first", + "the promise keeps the value it was resolved with" + ); + }, + function(reason) { + throw new Test262Error( + "the racing reject must not reject an already-resolved promise: " + reason + ); + } + ); +}); diff --git a/test/built-ins/Promise/safe-resolve-promise/latch-already-settled.js b/test/built-ins/Promise/safe-resolve-promise/latch-already-settled.js new file mode 100644 index 00000000000..49d97a71072 --- /dev/null +++ b/test/built-ins/Promise/safe-resolve-promise/latch-already-settled.js @@ -0,0 +1,67 @@ +// Copyright (C) 2026 Mozilla Corporation. All rights reserved. +// This code is governed by the BSD license found in the LICENSE file. + +/*--- +esid: sec-safe-promise-resolve +description: > + SafePromiseResolve is a no-op on a promise whose resolving functions have + already been used. +info: | + SafePromiseResolve ( promiseCapability, resolution ) + + 1. If RequiresDeferredPromiseResolution(_resolution_) is *false*, then + a. Return ? Call(_promiseCapability_.[[Resolve]], *undefined*, + « _resolution_ »). + ... + 7. Return ? Call(_promiseCapability_.[[Resolve]], *undefined*, + « _wrapper_ »). +includes: [asyncHelpers.js] +flags: [async] +features: [thenable-curtailment, safeResolvePromise, promise-with-resolvers] +---*/ + +var thenCallCount = 0; +var thenable = { + then: function(resolve) { + thenCallCount += 1; + resolve("must not be observed"); + }, +}; + +asyncTest(function() { + // Already fulfilled. + var fulfilled = Promise.withResolvers(); + fulfilled.resolve("fulfilled first"); + $262.safeResolvePromise(fulfilled.promise, thenable); + + var fulfilledCheck = fulfilled.promise.then(function(settledValue) { + assert.sameValue( + settledValue, + "fulfilled first", + "the promise keeps its original value" + ); + }); + + // Already rejected. + var reason = new Error("rejected first"); + var rejected = Promise.withResolvers(); + rejected.reject(reason); + $262.safeResolvePromise(rejected.promise, thenable); + + var rejectedCheck = rejected.promise.then( + function() { + throw new Test262Error("an already-rejected promise must stay rejected"); + }, + function(settledReason) { + assert.sameValue(settledReason, reason, "the promise keeps its original reason"); + } + ); + + return Promise.all([fulfilledCheck, rejectedCheck]).then(function() { + assert.sameValue( + thenCallCount, + 0, + "the ignored resolution's \"then\" is never called" + ); + }); +}); diff --git a/test/built-ins/Promise/safe-resolve-promise/latch-double-safe-resolve.js b/test/built-ins/Promise/safe-resolve-promise/latch-double-safe-resolve.js new file mode 100644 index 00000000000..1abfec7be04 --- /dev/null +++ b/test/built-ins/Promise/safe-resolve-promise/latch-double-safe-resolve.js @@ -0,0 +1,63 @@ +// Copyright (C) 2026 Mozilla Corporation. All rights reserved. +// This code is governed by the BSD license found in the LICENSE file. + +/*--- +esid: sec-safe-promise-resolve +description: > + A second call to SafePromiseResolve on the same promise is a no-op; the + first resolution wins. +info: | + SafePromiseResolve ( promiseCapability, resolution ) + + 7. Return ? Call(_promiseCapability_.[[Resolve]], *undefined*, + « _wrapper_ »). +includes: [asyncHelpers.js] +flags: [async] +features: [thenable-curtailment, safeResolvePromise, promise-with-resolvers] +---*/ + +asyncTest(function() { + // Both resolutions would be deferred. + var secondThenCallCount = 0; + + var deferred = Promise.withResolvers(); + $262.safeResolvePromise(deferred.promise, { + then: function(resolve) { + resolve("first"); + }, + }); + $262.safeResolvePromise(deferred.promise, { + then: function(resolve) { + secondThenCallCount += 1; + resolve("second"); + }, + }); + + var deferredCheck = deferred.promise.then(function(settledValue) { + assert.sameValue(settledValue, "first", "the first resolution wins"); + assert.sameValue( + secondThenCallCount, + 0, + "the second resolution's \"then\" is never called" + ); + }); + + // A deferred resolution followed by one which would be synchronous. + var mixed = Promise.withResolvers(); + $262.safeResolvePromise(mixed.promise, { + then: function(resolve) { + resolve("deferred first"); + }, + }); + $262.safeResolvePromise(mixed.promise, "synchronous second"); + + var mixedCheck = mixed.promise.then(function(settledValue) { + assert.sameValue( + settledValue, + "deferred first", + "a subsequent synchronous resolution cannot overtake a deferred one" + ); + }); + + return Promise.all([deferredCheck, mixedCheck]); +}); diff --git a/test/built-ins/Promise/safe-resolve-promise/latch-thenable-resolving-functions.js b/test/built-ins/Promise/safe-resolve-promise/latch-thenable-resolving-functions.js new file mode 100644 index 00000000000..fc22bc938e5 --- /dev/null +++ b/test/built-ins/Promise/safe-resolve-promise/latch-thenable-resolving-functions.js @@ -0,0 +1,104 @@ +// Copyright (C) 2026 Mozilla Corporation. All rights reserved. +// This code is governed by the BSD license found in the LICENSE file. + +/*--- +esid: sec-perform-promise-resolve-thenable +description: > + The pair of resolving functions passed to a deferred "then" shares a single + latch: the first settlement wins and the rest are no-ops. +info: | + PerformPromiseResolveThenable ( promiseToResolve, thenable, then ) + + 1. Let _resolvingFunctions_ be CreateResolvingFunctions(_promiseToResolve_). + 2. If _then_ is a function object, then + a. Let _thenCallResult_ be Completion(Call(_then_, _thenable_, + « _resolvingFunctions_.[[Resolve]], + _resolvingFunctions_.[[Reject]] »)). + ... + 4. If _thenCallResult_ is an abrupt completion, then + a. Return ! Call(_resolvingFunctions_.[[Reject]], *undefined*, + « _thenCallResult_.[[Value]] »). + + CreateResolvingFunctions ( toResolve ) + + 1. Let _promiseOrEmpty_ be the Record { [[Value]]: _toResolve_ }. + 2. Let _resolveSteps_ be a new Abstract Closure with parameters + (_resolution_) that captures _promiseOrEmpty_ ... + a. If _promiseOrEmpty_.[[Value]] is ~empty~, return *undefined*. + + Both functions close over the same Record, so using either one spends the + pair. Step 4 relies on this: a "then" which resolves and then throws must + keep its resolution rather than be rejected by the throw. +includes: [asyncHelpers.js] +flags: [async] +features: [thenable-curtailment, safeResolvePromise, promise-with-resolvers] +---*/ + +asyncTest(function() { + // resolve then reject: the rejection is ignored. + var resolveFirst = Promise.withResolvers(); + $262.safeResolvePromise(resolveFirst.promise, { + then: function(resolve, reject) { + resolve("resolved first"); + reject(new Error("must be ignored")); + }, + }); + var resolveFirstCheck = resolveFirst.promise.then( + function(settledValue) { + assert.sameValue( + settledValue, + "resolved first", + "a later reject cannot overtake the resolution" + ); + }, + function(reason) { + throw new Test262Error("the promise must not be rejected: " + reason); + } + ); + + // reject then resolve: the resolution is ignored. + var rejectReason = new Error("rejected first"); + var rejectFirst = Promise.withResolvers(); + $262.safeResolvePromise(rejectFirst.promise, { + then: function(resolve, reject) { + reject(rejectReason); + resolve("must be ignored"); + }, + }); + var rejectFirstCheck = rejectFirst.promise.then( + function(settledValue) { + throw new Test262Error("the promise must not be fulfilled: " + settledValue); + }, + function(reason) { + assert.sameValue( + reason, + rejectReason, + "a later resolve cannot overtake the rejection" + ); + } + ); + + // resolve then throw: PerformPromiseResolveThenable step 4 calls the spent + // reject function, which is a no-op. + var resolveThenThrow = Promise.withResolvers(); + $262.safeResolvePromise(resolveThenThrow.promise, { + then: function(resolve) { + resolve("resolved before throwing"); + throw new Error("must be ignored"); + }, + }); + var resolveThenThrowCheck = resolveThenThrow.promise.then( + function(settledValue) { + assert.sameValue( + settledValue, + "resolved before throwing", + "an exception after resolving cannot reject the promise" + ); + }, + function(reason) { + throw new Test262Error("the promise must not be rejected: " + reason); + } + ); + + return Promise.all([resolveFirstCheck, rejectFirstCheck, resolveThenThrowCheck]); +}); diff --git a/test/built-ins/Promise/safe-resolve-promise/sync-no-then.js b/test/built-ins/Promise/safe-resolve-promise/sync-no-then.js new file mode 100644 index 00000000000..c1765efbc86 --- /dev/null +++ b/test/built-ins/Promise/safe-resolve-promise/sync-no-then.js @@ -0,0 +1,88 @@ +// Copyright (C) 2026 Mozilla Corporation. All rights reserved. +// This code is governed by the BSD license found in the LICENSE file. + +/*--- +esid: sec-safe-promise-resolve +description: > + SafePromiseResolve fulfills synchronously when the resolution is an ordinary + object with no "then" property anywhere on its prototype chain. +info: | + PropertyAccessCouldRunUserCode ( o, propertyKey, kind ) + + 5. Let _desc_ be ! _o_.[[GetOwnProperty]](_propertyKey_). + 6. If _desc_ is not *undefined*, then + ... + 7. Let _proto_ be _o_.[[GetPrototypeOf]](). + 8. If _proto_ is *null*, return *false*. + 9. Return PropertyAccessCouldRunUserCode(_proto_, _propertyKey_, _kind_). + + RequiresDeferredPromiseResolution ( value ) + + 3. Let _thenValue_ be ! _value_.[[Get]](*"then"*). + 4. If IsCallable(_thenValue_) is *true*, return *true*. + 5. Return *false*. +includes: [asyncHelpers.js, compareArray.js] +flags: [async] +features: [thenable-curtailment, safeResolvePromise, promise-with-resolvers] +---*/ + +var expected = [ + "start", + "tick 1", + + // Each promise was resolved during the synchronous section, so all of their + // reactions were queued before "tick 2". + "settled plain object", + "settled null prototype", + "settled inherits from null prototype", + "settled array", + "settled function", + "settled error", + + "tick 2", +]; + +// None of Object.prototype, Array.prototype, Function.prototype, Error.prototype +// nor a null prototype provides a "then" property. +var values = [ + ["plain object", {}], + ["null prototype", Object.create(null)], + ["inherits from null prototype", Object.create(Object.create(null))], + ["array", [1, 2, 3]], + ["function", function() {}], + ["error", new Error("not a thenable")], +]; + +var actual = []; + +asyncTest(function() { + var ruler = Promise.resolve(0) + .then(() => actual.push("tick 1")) + .then(() => actual.push("tick 2")) + .then(() => { + assert.compareArray(actual, expected, "Ticks for objects without \"then\""); + }); + + var checks = []; + + values.forEach(function(entry) { + var label = entry[0]; + var value = entry[1]; + + var capability = Promise.withResolvers(); + $262.safeResolvePromise(capability.promise, value); + + checks.push(capability.promise.then(function(settledValue) { + actual.push("settled " + label); + assert.sameValue( + settledValue, + value, + "promise is fulfilled with the resolution itself: " + label + ); + })); + }); + + actual.push("start"); + + return Promise.all([ruler].concat(checks)); +}); diff --git a/test/built-ins/Promise/safe-resolve-promise/sync-non-object.js b/test/built-ins/Promise/safe-resolve-promise/sync-non-object.js new file mode 100644 index 00000000000..73f566c3ddf --- /dev/null +++ b/test/built-ins/Promise/safe-resolve-promise/sync-non-object.js @@ -0,0 +1,83 @@ +// Copyright (C) 2026 Mozilla Corporation. All rights reserved. +// This code is governed by the BSD license found in the LICENSE file. + +/*--- +esid: sec-safe-promise-resolve +description: > + SafePromiseResolve fulfills synchronously when the resolution is not an + Object. +info: | + RequiresDeferredPromiseResolution ( value ) + + 1. If _value_ is not an Object, return *false*. + + SafePromiseResolve ( promiseCapability, resolution ) + + 1. If RequiresDeferredPromiseResolution(_resolution_) is *false*, then + a. Return ? Call(_promiseCapability_.[[Resolve]], *undefined*, + « _resolution_ »). +includes: [asyncHelpers.js, compareArray.js] +flags: [async] +features: [thenable-curtailment, safeResolvePromise, promise-with-resolvers, Symbol, BigInt] +---*/ + +var expected = [ + "start", + "tick 1", + + // Each promise was resolved during the synchronous section, so all of their + // reactions were queued before "tick 2". + "settled undefined", + "settled null", + "settled boolean", + "settled number", + "settled string", + "settled symbol", + "settled bigint", + + "tick 2", +]; + +var values = [ + ["undefined", undefined], + ["null", null], + ["boolean", true], + ["number", 42], + ["string", "then"], + ["symbol", Symbol("desc")], + ["bigint", 17n], +]; + +var actual = []; + +asyncTest(function() { + var ruler = Promise.resolve(0) + .then(() => actual.push("tick 1")) + .then(() => actual.push("tick 2")) + .then(() => { + assert.compareArray(actual, expected, "Ticks for non-object resolutions"); + }); + + var checks = []; + + values.forEach(function(entry) { + var label = entry[0]; + var value = entry[1]; + + var capability = Promise.withResolvers(); + $262.safeResolvePromise(capability.promise, value); + + checks.push(capability.promise.then(function(settledValue) { + actual.push("settled " + label); + assert.sameValue( + settledValue, + value, + "promise is fulfilled with the resolution: " + label + ); + })); + }); + + actual.push("start"); + + return Promise.all([ruler].concat(checks)); +}); diff --git a/test/built-ins/Promise/safe-resolve-promise/sync-null-proto-ignores-object-prototype-then.js b/test/built-ins/Promise/safe-resolve-promise/sync-null-proto-ignores-object-prototype-then.js new file mode 100644 index 00000000000..ee318ae5142 --- /dev/null +++ b/test/built-ins/Promise/safe-resolve-promise/sync-null-proto-ignores-object-prototype-then.js @@ -0,0 +1,81 @@ +// Copyright (C) 2026 Mozilla Corporation. All rights reserved. +// This code is governed by the BSD license found in the LICENSE file. + +/*--- +esid: sec-has-property-which-could-run-user-code +description: > + An object with a null prototype resolves synchronously even when + Object.prototype has a callable "then". +info: | + PropertyAccessCouldRunUserCode ( o, propertyKey, kind ) + + 7. Let _proto_ be _o_.[[GetPrototypeOf]](). + 8. If _proto_ is *null*, return *false*. +includes: [asyncHelpers.js, compareArray.js] +flags: [async] +features: [thenable-curtailment, safeResolvePromise, promise-with-resolvers] +---*/ + +var expected = [ + // The chain walk ends at the null prototype without reaching + // Object.prototype, so the promise is fulfilled here rather than in a job. + "start", + "tick 1", + + // Resolved during the synchronous section, so its reaction is already queued. + "settled", + + "tick 2", +]; + +var actual = []; + +var thenCallCount = 0; +var value = Object.create(null); + +Object.defineProperty(Object.prototype, "then", { + value: function(resolve) { + thenCallCount += 1; + resolve("from Object.prototype"); + }, + writable: true, + enumerable: false, + configurable: true, +}); + +asyncTest(function() { + var ruler = Promise.resolve(0) + .then(() => actual.push("tick 1")) + .then(() => actual.push("tick 2")) + .then(() => { + try { + assert.compareArray( + actual, + expected, + "Ticks for a null-prototype object" + ); + assert.sameValue( + thenCallCount, + 0, + "Object.prototype.then is never called" + ); + } finally { + delete Object.prototype.then; + } + }); + + var capability = Promise.withResolvers(); + $262.safeResolvePromise(capability.promise, value); + actual.push("start"); + + var settled = capability.promise.then(function(settledValue) { + actual.push("settled"); + assert.sameValue( + settledValue, + value, + "promise is fulfilled with the resolution itself" + ); + }); + + return Promise.all([ruler, settled]); +}); diff --git a/test/built-ins/Promise/safe-resolve-promise/sync-own-non-callable-then-shadows-callable.js b/test/built-ins/Promise/safe-resolve-promise/sync-own-non-callable-then-shadows-callable.js new file mode 100644 index 00000000000..8e50b1231f1 --- /dev/null +++ b/test/built-ins/Promise/safe-resolve-promise/sync-own-non-callable-then-shadows-callable.js @@ -0,0 +1,71 @@ +// Copyright (C) 2026 Mozilla Corporation. All rights reserved. +// This code is governed by the BSD license found in the LICENSE file. + +/*--- +esid: sec-has-property-which-could-run-user-code +description: > + SafePromiseResolve fulfills synchronously when an own non-callable "then" + data property shadows a callable "then" on the prototype chain. +info: | + PropertyAccessCouldRunUserCode ( o, propertyKey, kind ) + + 5. Let _desc_ be ! _o_.[[GetOwnProperty]](_propertyKey_). + 6. If _desc_ is not *undefined*, then + a. If IsAccessorDescriptor(_desc_) is *true*, then + ... + b. Return *false*. +includes: [asyncHelpers.js, compareArray.js] +flags: [async] +features: [thenable-curtailment, safeResolvePromise, promise-with-resolvers] +---*/ + +var expected = [ + // The chain walk stops at the own "then", never reaching the callable one. + "start", + "tick 1", + + // Resolved during the synchronous section, so its reaction is already queued. + "settled", + + "tick 2", +]; + +var actual = []; + +var proto = { + then: function(resolve) { + actual.push("call inherited then"); + resolve("from the prototype"); + }, +}; + +var value = Object.create(proto); +value.then = 42; + +asyncTest(function() { + var ruler = Promise.resolve(0) + .then(() => actual.push("tick 1")) + .then(() => actual.push("tick 2")) + .then(() => { + assert.compareArray( + actual, + expected, + "Ticks for a shadowed callable \"then\"" + ); + }); + + var capability = Promise.withResolvers(); + $262.safeResolvePromise(capability.promise, value); + actual.push("start"); + + var settled = capability.promise.then(function(settledValue) { + actual.push("settled"); + assert.sameValue( + settledValue, + value, + "promise is fulfilled with the resolution itself" + ); + }); + + return Promise.all([ruler, settled]); +}); diff --git a/test/built-ins/Promise/safe-resolve-promise/sync-self-resolution.js b/test/built-ins/Promise/safe-resolve-promise/sync-self-resolution.js new file mode 100644 index 00000000000..0c09f9f6e85 --- /dev/null +++ b/test/built-ins/Promise/safe-resolve-promise/sync-self-resolution.js @@ -0,0 +1,86 @@ +// Copyright (C) 2026 Mozilla Corporation. All rights reserved. +// This code is governed by the BSD license found in the LICENSE file. + +/*--- +esid: sec-safe-promise-resolve +description: > + Resolving a promise with itself through SafePromiseResolve rejects it + synchronously when the promise does not look thenable. +info: | + SafePromiseResolve ( promiseCapability, resolution ) + + 1. If RequiresDeferredPromiseResolution(_resolution_) is *false*, then + a. Return ? Call(_promiseCapability_.[[Resolve]], *undefined*, + « _resolution_ »). + + CreateResolvingFunctions ( toResolve ), resolve steps + + 4. Perform ? PerformPromiseResolution(_promise_, _resolution_, ~sync~). + + PerformPromiseResolution ( promise, resolution, thenCallTiming ) + + 2. If SameValue(_resolution_, _promise_) is *true*, then + a. Let _selfResolutionError_ be a newly created *TypeError* object. + b. Perform RejectPromise(_promise_, _selfResolutionError_). +includes: [asyncHelpers.js, compareArray.js] +flags: [async] +features: [thenable-curtailment, safeResolvePromise, promise-with-resolvers] +---*/ + +// SafePromiseResolve does not special-case a resolution which is the promise +// itself; the self-resolution check belongs to PerformPromiseResolution, which +// both paths reach. Shadowing "then" with a non-callable value makes +// RequiresDeferredPromiseResolution report false, so the synchronous path runs +// and the TypeError is reported one microtask earlier than in +// deferred-self-resolution.js. The rejection timing of a self resolution +// therefore depends on whether the promise looks thenable. +var expected = [ + "start", + "tick 1", + + // Rejected during the synchronous section, so its reaction is already queued. + "rejected", + + "tick 2", +]; + +var actual = []; + +asyncTest(function() { + var ruler = Promise.resolve(0) + .then(() => actual.push("tick 1")) + .then(() => actual.push("tick 2")) + .then(() => { + assert.compareArray(actual, expected, "Ticks for a synchronous self resolution"); + }); + + var capability = Promise.withResolvers(); + Object.defineProperty(capability.promise, "then", { + value: 42, + writable: true, + enumerable: false, + configurable: true, + }); + + $262.safeResolvePromise(capability.promise, capability.promise); + actual.push("start"); + + // "then" is shadowed on the promise, so reactions must be attached through + // Promise.prototype.then directly. + var settled = Promise.prototype.then.call( + capability.promise, + function(settledValue) { + throw new Test262Error("the promise must not be fulfilled: " + settledValue); + }, + function(reason) { + actual.push("rejected"); + assert.sameValue( + reason instanceof TypeError, + true, + "the promise is rejected with a TypeError" + ); + } + ); + + return Promise.all([ruler, settled]); +}); diff --git a/test/built-ins/Promise/safe-resolve-promise/sync-then-not-callable-proto.js b/test/built-ins/Promise/safe-resolve-promise/sync-then-not-callable-proto.js new file mode 100644 index 00000000000..02c6f917054 --- /dev/null +++ b/test/built-ins/Promise/safe-resolve-promise/sync-then-not-callable-proto.js @@ -0,0 +1,83 @@ +// Copyright (C) 2026 Mozilla Corporation. All rights reserved. +// This code is governed by the BSD license found in the LICENSE file. + +/*--- +esid: sec-safe-promise-resolve +description: > + SafePromiseResolve fulfills synchronously when a non-callable "then" data + property is inherited from the prototype chain. +info: | + PropertyAccessCouldRunUserCode ( o, propertyKey, kind ) + + 5. Let _desc_ be ! _o_.[[GetOwnProperty]](_propertyKey_). + 6. If _desc_ is not *undefined*, then + a. If IsAccessorDescriptor(_desc_) is *true*, then + ... + b. Return *false*. + 7. Let _proto_ be _o_.[[GetPrototypeOf]](). + 8. If _proto_ is *null*, return *false*. + 9. Return PropertyAccessCouldRunUserCode(_proto_, _propertyKey_, _kind_). +includes: [asyncHelpers.js, compareArray.js] +flags: [async] +features: [thenable-curtailment, safeResolvePromise, promise-with-resolvers] +---*/ + +var expected = [ + "start", + "tick 1", + + // Walking the chain to find a non-callable "then" cannot run user code, + // however deep the chain is, so both promises settle before "tick 2". + "settled immediate", + "settled deep", + + "tick 2", +]; + +// Inherited from the immediate prototype, and from further up a chain rooted at +// a null prototype. +var deepRoot = Object.create(null); +deepRoot.then = "not callable"; + +var values = [ + ["immediate", Object.create({ then: 42 })], + ["deep", Object.create(Object.create(Object.create(deepRoot)))], +]; + +var actual = []; + +asyncTest(function() { + var ruler = Promise.resolve(0) + .then(() => actual.push("tick 1")) + .then(() => actual.push("tick 2")) + .then(() => { + assert.compareArray( + actual, + expected, + "Ticks for an inherited non-callable \"then\"" + ); + }); + + var checks = []; + + values.forEach(function(entry) { + var label = entry[0]; + var value = entry[1]; + + var capability = Promise.withResolvers(); + $262.safeResolvePromise(capability.promise, value); + + checks.push(capability.promise.then(function(settledValue) { + actual.push("settled " + label); + assert.sameValue( + settledValue, + value, + "promise is fulfilled with the resolution itself: " + label + ); + })); + }); + + actual.push("start"); + + return Promise.all([ruler].concat(checks)); +}); diff --git a/test/built-ins/Promise/safe-resolve-promise/sync-then-not-callable.js b/test/built-ins/Promise/safe-resolve-promise/sync-then-not-callable.js new file mode 100644 index 00000000000..a5a11c6cb2a --- /dev/null +++ b/test/built-ins/Promise/safe-resolve-promise/sync-then-not-callable.js @@ -0,0 +1,77 @@ +// Copyright (C) 2026 Mozilla Corporation. All rights reserved. +// This code is governed by the BSD license found in the LICENSE file. + +/*--- +esid: sec-safe-promise-resolve +description: > + SafePromiseResolve fulfills synchronously when the resolution has an own + "then" data property whose value is not callable. +info: | + RequiresDeferredPromiseResolution ( value ) + + 3. Let _thenValue_ be ! _value_.[[Get]](*"then"*). + 4. If IsCallable(_thenValue_) is *true*, return *true*. + 5. Return *false*. +includes: [asyncHelpers.js, compareArray.js] +flags: [async] +features: [thenable-curtailment, safeResolvePromise, promise-with-resolvers, Symbol] +---*/ + +var expected = [ + "start", + "tick 1", + + // Each promise was resolved during the synchronous section, so all of their + // reactions were queued before "tick 2". + "settled then: undefined", + "settled then: null", + "settled then: number", + "settled then: string", + "settled then: symbol", + "settled then: object", + + "tick 2", +]; + +var thenValues = [ + ["undefined", undefined], + ["null", null], + ["number", 42], + ["string", "then"], + ["symbol", Symbol("then")], + ["object", {}], +]; + +var actual = []; + +asyncTest(function() { + var ruler = Promise.resolve(0) + .then(() => actual.push("tick 1")) + .then(() => actual.push("tick 2")) + .then(() => { + assert.compareArray(actual, expected, "Ticks for a non-callable \"then\""); + }); + + var checks = []; + + thenValues.forEach(function(entry) { + var label = entry[0]; + var value = { then: entry[1] }; + + var capability = Promise.withResolvers(); + $262.safeResolvePromise(capability.promise, value); + + checks.push(capability.promise.then(function(settledValue) { + actual.push("settled then: " + label); + assert.sameValue( + settledValue, + value, + "promise is fulfilled with the resolution itself, then: " + label + ); + })); + }); + + actual.push("start"); + + return Promise.all([ruler].concat(checks)); +}); diff --git a/test/built-ins/Promise/safe-resolve-promise/sync-then-setter-only-proto.js b/test/built-ins/Promise/safe-resolve-promise/sync-then-setter-only-proto.js new file mode 100644 index 00000000000..933313067dc --- /dev/null +++ b/test/built-ins/Promise/safe-resolve-promise/sync-then-setter-only-proto.js @@ -0,0 +1,78 @@ +// Copyright (C) 2026 Mozilla Corporation. All rights reserved. +// This code is governed by the BSD license found in the LICENSE file. + +/*--- +esid: sec-has-property-which-could-run-user-code +description: > + SafePromiseResolve fulfills synchronously when a "then" accessor with a + setter but no getter is inherited from the prototype chain. +info: | + PropertyAccessCouldRunUserCode ( o, propertyKey, kind ) + + 6. If _desc_ is not *undefined*, then + a. If IsAccessorDescriptor(_desc_) is *true*, then + i. If _kind_ is either ~any~ or ~get~, and _desc_.[[Get]] is not + *undefined*, return *true*. + ii. If _kind_ is either ~any~ or ~set~, and _desc_.[[Set]] is not + *undefined*, return *true*. + b. Return *false*. + 7. Let _proto_ be _o_.[[GetPrototypeOf]](). + ... + 9. Return PropertyAccessCouldRunUserCode(_proto_, _propertyKey_, _kind_). +includes: [asyncHelpers.js, compareArray.js] +flags: [async] +features: [thenable-curtailment, safeResolvePromise, promise-with-resolvers] +---*/ + +var expected = [ + // Reading a setter-only "then" cannot run user code, so this is not deferred. + "start", + "tick 1", + + // Resolved during the synchronous section, so its reaction is already queued. + "settled", + + "tick 2", +]; + +var actual = []; + +var setterCallCount = 0; +var proto = {}; +Object.defineProperty(proto, "then", { + set: function(_v) { + setterCallCount += 1; + }, + configurable: true, +}); + +var value = Object.create(proto); + +asyncTest(function() { + var ruler = Promise.resolve(0) + .then(() => actual.push("tick 1")) + .then(() => actual.push("tick 2")) + .then(() => { + assert.compareArray( + actual, + expected, + "Ticks for an inherited setter-only \"then\"" + ); + assert.sameValue(setterCallCount, 0, "the setter is never called"); + }); + + var capability = Promise.withResolvers(); + $262.safeResolvePromise(capability.promise, value); + actual.push("start"); + + var settled = capability.promise.then(function(settledValue) { + actual.push("settled"); + assert.sameValue( + settledValue, + value, + "promise is fulfilled with the resolution itself" + ); + }); + + return Promise.all([ruler, settled]); +}); diff --git a/test/built-ins/Promise/safe-resolve-promise/sync-then-setter-only.js b/test/built-ins/Promise/safe-resolve-promise/sync-then-setter-only.js new file mode 100644 index 00000000000..bb6f2e58e28 --- /dev/null +++ b/test/built-ins/Promise/safe-resolve-promise/sync-then-setter-only.js @@ -0,0 +1,69 @@ +// Copyright (C) 2026 Mozilla Corporation. All rights reserved. +// This code is governed by the BSD license found in the LICENSE file. + +/*--- +esid: sec-has-property-which-could-run-user-code +description: > + SafePromiseResolve fulfills synchronously when the resolution has an own + "then" accessor with a setter but no getter. +info: | + PropertyAccessCouldRunUserCode ( o, propertyKey, kind ) + + 6. If _desc_ is not *undefined*, then + a. If IsAccessorDescriptor(_desc_) is *true*, then + i. If _kind_ is either ~any~ or ~get~, and _desc_.[[Get]] is not + *undefined*, return *true*. + ii. If _kind_ is either ~any~ or ~set~, and _desc_.[[Set]] is not + *undefined*, return *true*. + b. Return *false*. +includes: [asyncHelpers.js, compareArray.js] +flags: [async] +features: [thenable-curtailment, safeResolvePromise, promise-with-resolvers] +---*/ + +var expected = [ + // Reading a setter-only "then" cannot run user code, so this is not deferred. + "start", + "tick 1", + + // Resolved during the synchronous section, so its reaction is already queued. + "settled", + + "tick 2", +]; + +var actual = []; + +var setterCallCount = 0; +var value = {}; +Object.defineProperty(value, "then", { + set: function(_v) { + setterCallCount += 1; + }, + configurable: true, +}); + +asyncTest(function() { + var ruler = Promise.resolve(0) + .then(() => actual.push("tick 1")) + .then(() => actual.push("tick 2")) + .then(() => { + assert.compareArray(actual, expected, "Ticks for a setter-only \"then\""); + assert.sameValue(setterCallCount, 0, "the setter is never called"); + }); + + var capability = Promise.withResolvers(); + $262.safeResolvePromise(capability.promise, value); + actual.push("start"); + + var settled = capability.promise.then(function(settledValue) { + actual.push("settled"); + assert.sameValue( + settledValue, + value, + "promise is fulfilled with the resolution itself" + ); + }); + + return Promise.all([ruler, settled]); +});