search

Module selenium-webdriver/lib/promise

IMPORTANT NOTICE

The promise manager contained in this module is in the process of being phased out in favor of native JavaScript promises. This will be a long process and will not be completed until there have been two major LTS Node releases (approx. Node v10.0) that support async functions.

At this time, the promise manager can be disabled by setting an environment variable, SELENIUM_PROMISE_MANAGER=0. In the absence of async functions, users may use generators with the promise.consume() function to write "synchronous" style tests:

const {Builder, By, promise, until} = require('selenium-webdriver');

let result = promise.consume(function* doGoogleSearch() {
  let driver = new Builder().forBrowser('firefox').build();
  yield driver.get('http://www.google.com/ncr');
  yield driver.findElement(By.name('q')).sendKeys('webdriver');
  yield driver.findElement(By.name('btnG')).click();
  yield driver.wait(until.titleIs('webdriver - Google Search'), 1000);
  yield driver.quit();
});

result.then(_ => console.log('SUCCESS!'),
            e => console.error('FAILURE: ' + e));

The motivation behind this change and full deprecation plan are documented in issue 2969.

The promise module is centered around the ControlFlow, a class that coordinates the execution of asynchronous tasks. The ControlFlow allows users to focus on the imperative commands for their script without worrying about chaining together every single asynchronous action, which can be tedious and verbose. APIs may be layered on top of the control flow to read as if they were synchronous. For instance, the core WebDriver API is built on top of the control flow, allowing users to write

 driver.get('http://www.google.com/ncr');
 driver.findElement({name: 'q'}).sendKeys('webdriver');
 driver.findElement({name: 'btnGn'}).click();

instead of

 driver.get('http://www.google.com/ncr')
 .then(function() {
   return driver.findElement({name: 'q'});
 })
 .then(function(q) {
   return q.sendKeys('webdriver');
 })
 .then(function() {
   return driver.findElement({name: 'btnG'});
 })
 .then(function(btnG) {
   return btnG.click();
 });

Tasks and Task Queues

The control flow is based on the concept of tasks and task queues. Tasks are functions that define the basic unit of work for the control flow to execute. Each task is scheduled via ControlFlow#execute(), which will return a ManagedPromise that will be resolved with the task's result.

A task queue contains all of the tasks scheduled within a single turn of the JavaScript event loop. The control flow will create a new task queue the first time a task is scheduled within an event loop.

 var flow = promise.controlFlow();
 flow.execute(foo);       // Creates a new task queue and inserts foo.
 flow.execute(bar);       // Inserts bar into the same queue as foo.
 setTimeout(function() {
   flow.execute(baz);     // Creates a new task queue and inserts baz.
 }, 0);

Whenever the control flow creates a new task queue, it will automatically begin executing tasks in the next available turn of the event loop. This execution is scheduled as a microtask like e.g. a (native) Promise.then() callback.

 setTimeout(() => console.log('a'));
 Promise.resolve().then(() => console.log('b'));  // A native promise.
 flow.execute(() => console.log('c'));
 Promise.resolve().then(() => console.log('d'));
 setTimeout(() => console.log('fin'));
 // b
 // c
 // d
 // a
 // fin

In the example above, b/c/d is logged before a/fin because native promises and this module use "microtask" timers, which have a higher priority than "macrotasks" like setTimeout.

Task Execution

Upon creating a task queue, and whenever an existing queue completes a task, the control flow will schedule a microtask timer to process any scheduled tasks. This ensures no task is ever started within the same turn of the JavaScript event loop in which it was scheduled, nor is a task ever started within the same turn that another finishes.

When the execution timer fires, a single task will be dequeued and executed. There are several important events that may occur while executing a task function:

  1. A new task queue is created by a call to ControlFlow#execute(). Any tasks scheduled within this task queue are considered subtasks of the current task.
  2. The task function throws an error. Any scheduled tasks are immediately discarded and the task's promised result (previously returned by ControlFlow#execute()) is immediately rejected with the thrown error.
  3. The task function returns successfully.

If a task function created a new task queue, the control flow will wait for that queue to complete before processing the task result. If the queue completes without error, the flow will settle the task's promise with the value originally returned by the task function. On the other hand, if the task queue terminates with an error, the task's promise will be rejected with that error.

 flow.execute(function() {
   flow.execute(() => console.log('a'));
   flow.execute(() => console.log('b'));
 });
 flow.execute(() => console.log('c'));
 // a
 // b
 // c

ManagedPromise Integration

In addition to the ControlFlow class, the promise module also exports a Promises/A+ implementation that is deeply integrated with the ControlFlow. First and foremost, each promise callback is scheduled with the control flow as a task. As a result, each callback is invoked in its own turn of the JavaScript event loop with its own task queue. If any tasks are scheduled within a callback, the callback's promised result will not be settled until the task queue has completed.

 promise.fulfilled().then(function() {
   flow.execute(function() {
     console.log('b');
   });
 }).then(() => console.log('a'));
 // b
 // a

Scheduling ManagedPromise Callbacks

How callbacks are scheduled in the control flow depends on when they are attached to the promise. Callbacks attached to a previously resolved promise are immediately enqueued as subtasks of the currently running task.

 var p = promise.fulfilled();
 flow.execute(function() {
   flow.execute(() => console.log('A'));
   p.then(      () => console.log('B'));
   flow.execute(() => console.log('C'));
   p.then(      () => console.log('D'));
 }).then(function() {
   console.log('fin');
 });
 // A
 // B
 // C
 // D
 // fin

When a promise is resolved while a task function is on the call stack, any callbacks also registered in that stack frame are scheduled as if the promise were already resolved:

 var d = promise.defer();
 flow.execute(function() {
   flow.execute(  () => console.log('A'));
   d.promise.then(() => console.log('B'));
   flow.execute(  () => console.log('C'));
   d.promise.then(() => console.log('D'));

   d.fulfill();
 }).then(function() {
   console.log('fin');
 });
 // A
 // B
 // C
 // D
 // fin

Callbacks attached to an unresolved promise within a task function are only weakly scheduled as subtasks and will be dropped if they reach the front of the queue before the promise is resolved. In the example below, the callbacks for B & D are dropped as sub-tasks since they are attached to an unresolved promise when they reach the front of the task queue.

 var d = promise.defer();
 flow.execute(function() {
   flow.execute(  () => console.log('A'));
   d.promise.then(() => console.log('B'));
   flow.execute(  () => console.log('C'));
   d.promise.then(() => console.log('D'));

   setTimeout(d.fulfill, 20);
 }).then(function() {
   console.log('fin')
 });
 // A
 // C
 // fin
 // B
 // D

If a promise is resolved while a task function is on the call stack, any previously registered and unqueued callbacks (i.e. either attached while no task was on the call stack, or previously dropped as described above) act as interrupts and are inserted at the front of the task queue. If multiple promises are fulfilled, their interrupts are enqueued in the order the promises are resolved.

 var d1 = promise.defer();
 d1.promise.then(() => console.log('A'));

 var d2 = promise.defer();
 d2.promise.then(() => console.log('B'));

 flow.execute(function() {
   d1.promise.then(() => console.log('C'));
   flow.execute(() => console.log('D'));
 });
 flow.execute(function() {
   flow.execute(() => console.log('E'));
   flow.execute(() => console.log('F'));
   d1.fulfill();
   d2.fulfill();
 }).then(function() {
   console.log('fin');
 });
 // D
 // A
 // C
 // B
 // E
 // F
 // fin

Within a task function (or callback), each step of a promise chain acts as an interrupt on the task queue:

 var d = promise.defer();
 flow.execute(function() {
   d.promise.
       then(() => console.log('A')).
       then(() => console.log('B')).
       then(() => console.log('C')).
       then(() => console.log('D'));

   flow.execute(() => console.log('E'));
   d.fulfill();
 }).then(function() {
   console.log('fin');
 });
 // A
 // B
 // C
 // D
 // E
 // fin

If there are multiple promise chains derived from a single promise, they are processed in the order created:

 var d = promise.defer();
 flow.execute(function() {
   var chain = d.promise.then(() => console.log('A'));

   chain.then(() => console.log('B')).
       then(() => console.log('C'));

   chain.then(() => console.log('D')).
       then(() => console.log('E'));

   flow.execute(() => console.log('F'));

   d.fulfill();
 }).then(function() {
   console.log('fin');
 });
 // A
 // B
 // C
 // D
 // E
 // F
 // fin

Even though a subtask's promised result will never resolve while the task function is on the stack, it will be treated as a promise resolved within the task. In all other scenarios, a task's promise behaves just like a normal promise. In the sample below, C/D is logged before B because the resolution of subtask1 interrupts the flow of the enclosing task. Within the final subtask, E/F is logged in order because subtask1 is a resolved promise when that task runs.

 flow.execute(function() {
   var subtask1 = flow.execute(() => console.log('A'));
   var subtask2 = flow.execute(() => console.log('B'));

   subtask1.then(() => console.log('C'));
   subtask1.then(() => console.log('D'));

   flow.execute(function() {
     flow.execute(() => console.log('E'));
     subtask1.then(() => console.log('F'));
   });
 }).then(function() {
   console.log('fin');
 });
 // A
 // C
 // D
 // B
 // E
 // F
 // fin

Finally, consider the following:

 var d = promise.defer();
 d.promise.then(() => console.log('A'));
 d.promise.then(() => console.log('B'));

 flow.execute(function() {
   flow.execute(  () => console.log('C'));
   d.promise.then(() => console.log('D'));

   flow.execute(  () => console.log('E'));
   d.promise.then(() => console.log('F'));

   d.fulfill();

   flow.execute(  () => console.log('G'));
   d.promise.then(() => console.log('H'));
 }).then(function() {
   console.log('fin');
 });
 // A
 // B
 // C
 // D
 // E
 // F
 // G
 // H
 // fin

In this example, callbacks are registered on d.promise both before and during the invocation of the task function. When d.fulfill() is called, the callbacks registered before the task (A & B) are registered as interrupts. The remaining callbacks were all attached within the task and are scheduled in the flow as standard tasks.

Generator Support

Generators may be scheduled as tasks within a control flow or attached as callbacks to a promise. Each time the generator yields a promise, the control flow will wait for that promise to settle before executing the next iteration of the generator. The yielded promise's fulfilled value will be passed back into the generator:

 flow.execute(function* () {
   var d = promise.defer();

   setTimeout(() => console.log('...waiting...'), 25);
   setTimeout(() => d.fulfill(123), 50);

   console.log('start: ' + Date.now());

   var value = yield d.promise;
   console.log('mid: %d; value = %d', Date.now(), value);

   yield promise.delayed(10);
   console.log('end: ' + Date.now());
 }).then(function() {
   console.log('fin');
 });
 // start: 0
 // ...waiting...
 // mid: 50; value = 123
 // end: 60
 // fin

Yielding the result of a promise chain will wait for the entire chain to complete:

 promise.fulfilled().then(function* () {
   console.log('start: ' + Date.now());

   var value = yield flow.
       execute(() => console.log('A')).
       then(   () => console.log('B')).
       then(   () => 123);

   console.log('mid: %s; value = %d', Date.now(), value);

   yield flow.execute(() => console.log('C'));
 }).then(function() {
   console.log('fin');
 });
 // start: 0
 // A
 // B
 // mid: 2; value = 123
 // C
 // fin

Yielding a rejected promise will cause the rejected value to be thrown within the generator function:

 flow.execute(function* () {
   console.log('start: ' + Date.now());
   try {
     yield promise.delayed(10).then(function() {
       throw Error('boom');
     });
   } catch (ex) {
     console.log('caught time: ' + Date.now());
     console.log(ex.message);
   }
 });
 // start: 0
 // caught time: 10
 // boom

Error Handling

ES6 promises do not require users to handle a promise rejections. This can result in subtle bugs as the rejections are silently "swallowed" by the Promise class.

 Promise.reject(Error('boom'));
 // ... *crickets* ...

Selenium's promise module, on the other hand, requires that every rejection be explicitly handled. When a ManagedPromise is rejected and no callbacks are defined on that promise, it is considered an unhandled rejection and reported to the active task queue. If the rejection remains unhandled after a single turn of the event loop (scheduled with a microtask), it will propagate up the stack.

Error Propagation

If an unhandled rejection occurs within a task function, that task's promised result is rejected and all remaining subtasks are discarded:

 flow.execute(function() {
   // No callbacks registered on promise -> unhandled rejection
   promise.rejected(Error('boom'));
   flow.execute(function() { console.log('this will never run'); });
 }).catch(function(e) {
   console.log(e.message);
 });
 // boom

The promised results for discarded tasks are silently rejected with a cancellation error and existing callback chains will never fire.

 flow.execute(function() {
   promise.rejected(Error('boom'));
   flow.execute(function() { console.log('a'); }).
       then(function() { console.log('b'); });
 }).catch(function(e) {
   console.log(e.message);
 });
 // boom

An unhandled rejection takes precedence over a task function's returned result, even if that value is another promise:

 flow.execute(function() {
   promise.rejected(Error('boom'));
   return flow.execute(someOtherTask);
 }).catch(function(e) {
   console.log(e.message);
 });
 // boom

If there are multiple unhandled rejections within a task, they are packaged in a MultipleUnhandledRejectionError, which has an errors property that is a Set of the recorded unhandled rejections:

 flow.execute(function() {
   promise.rejected(Error('boom1'));
   promise.rejected(Error('boom2'));
 }).catch(function(ex) {
   console.log(ex instanceof MultipleUnhandledRejectionError);
   for (var e of ex.errors) {
     console.log(e.message);
   }
 });
 // boom1
 // boom2

When a subtask is discarded due to an unreported rejection in its parent frame, the existing callbacks on that task will never settle and the callbacks will not be invoked. If a new callback is attached to the subtask after it has been discarded, it is handled the same as adding a callback to a cancelled promise: the error-callback path is invoked. This behavior is intended to handle cases where the user saves a reference to a task promise, as illustrated below.

 var subTask;
 flow.execute(function() {
   promise.rejected(Error('boom'));
   subTask = flow.execute(function() {});
 }).catch(function(e) {
   console.log(e.message);
 }).then(function() {
   return subTask.then(
       () => console.log('subtask success!'),
       (e) => console.log('subtask failed:\n' + e));
 });
 // boom
 // subtask failed:
 // DiscardedTaskError: Task was discarded due to a previous failure: boom

When a subtask fails, its promised result is treated the same as any other promise: it must be handled within one turn of the rejection or the unhandled rejection is propagated to the parent task. This means users can catch errors from complex flows from the top level task:

 flow.execute(function() {
   flow.execute(function() {
     flow.execute(function() {
       throw Error('fail!');
     });
   });
 }).catch(function(e) {
   console.log(e.message);
 });
 // fail!

Unhandled Rejection Events

When an unhandled rejection propagates to the root of the control flow, the flow will emit an uncaughtException event. If no listeners are registered on the flow, the error will be rethrown to the global error handler: an uncaughtException event from the process object in node, or window.onerror when running in a browser.

Bottom line: you must handle rejected promises.

Promises/A+ Compatibility

This promise module is compliant with the Promises/A+ specification except for sections 2.2.6.1 and 2.2.6.2:

  • then may be called multiple times on the same promise.
    • If/when promise is fulfilled, all respective onFulfilled callbacks must execute in the order of their originating calls to then.
    • If/when promise is rejected, all respective onRejected callbacks must execute in the order of their originating calls to then.

Specifically, the conformance tests contain the following scenario (for brevity, only the fulfillment version is shown):

 var p1 = Promise.resolve();
 p1.then(function() {
   console.log('A');
   p1.then(() => console.log('B'));
 });
 p1.then(() => console.log('C'));
 // A
 // C
 // B

Since the ControlFlow executes promise callbacks as tasks, with this module, the result would be:

 var p2 = promise.fulfilled();
 p2.then(function() {
   console.log('A');
   p2.then(() => console.log('B');
 });
 p2.then(() => console.log('C'));
 // A
 // B
 // C

Exported Functions

Exported Properties

Exported Interfaces

Exported Classes