Skip to content

Commit

Permalink
feat(SELENIUM_PROMISE_MANAGER): Don't rely on webdriver.promise fun…
Browse files Browse the repository at this point in the history
…ctions

While we support `SELENIUM_PROMISE_MANAGER=0` already, we rely on `SimpleScheduler` and some other
utility functions which will be going away after the control flow has been fully deprecated.  This
commit allows jasminewd to work without those utility functions, and even allows people to pass
jasminewd their own custom scheduler implementation.

This does not fix our tests, which will also break when those utility functions go away.  See
angular#81

Closes angular#80
  • Loading branch information
sjelin committed Jan 21, 2017
1 parent 171cbde commit 8c04766
Show file tree
Hide file tree
Showing 6 changed files with 182 additions and 39 deletions.
102 changes: 66 additions & 36 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,31 +49,42 @@ function validateString(stringtoValidate) {
}
}

var idleEventName = 'idle';
try {
idleEventName = webdriver.promise.ControlFlow.EventType.IDLE;
} catch(e) {}

/**
* Calls a function once the control flow is idle
* @param {webdriver.promise.ControlFlow} flow The Web Driver control flow
* @param {!Function} fn The function to call
* Calls a function once the scheduler is idle. If the scheduler does not support the idle API,
* calls the function immediately. See scheduler.md#idle-api for details.
*
* @param {Object} scheduler The scheduler to wait for.
* @param {!Function} fn The function to call.
*/
function callWhenIdle(flow, fn) {
if (!flow.isIdle || flow.isIdle()) {
function callWhenIdle(scheduler, fn) {
if (!scheduler.once || !scheduler.isIdle || scheduler.isIdle()) {
fn();
} else {
flow.once(webdriver.promise.ControlFlow.EventType.IDLE, function() {
fn();
});
scheduler.once(idleEventName, function() { fn(); });
}
}


/**
* Wraps a function so it runs inside a webdriver.promise.ControlFlow and
* waits for the flow to complete before continuing.
* @param {!webdriver.promise.ControlFlow} flow The WebDriver control flow.
* Wraps a function so it runs inside a scheduler's `execute()` block.
*
* In the most common case, this means wrapping in a `webdriver.promise.ControlFlow` instance
* to wait for the control flow to complete one task before starting the next. See scheduler.md
* for details.
*
* @param {!Object} scheduler See scheduler.md for details.
* @param {!Function} newPromise Makes a new promise using whatever implementation the scheduler
* prefers.
* @param {!Function} globalFn The function to wrap.
* @param {!string} fnName The name of the function being wrapped (e.g. `'it'`).
* @return {!Function} The new function.
*/
function wrapInControlFlow(flow, globalFn, fnName) {
function wrapInScheduler(scheduler, newPromise, globalFn, fnName) {
return function() {
var driverError = new Error();
driverError.stack = driverError.stack.replace(/ +at.+jasminewd.+\n/, '');
Expand All @@ -84,14 +95,7 @@ function wrapInControlFlow(flow, globalFn, fnName) {
var async = fn.length > 0;
var testFn = fn.bind(this);

flow.execute(function controlFlowExecute() {
function newPromise(resolver) {
if (typeof flow.promise == 'function') {
return flow.promise(resolver);
} else {
return new webdriver.promise.Promise(resolver, flow);
}
}
scheduler.execute(function schedulerExecute() {
return newPromise(function(fulfill, reject) {
function wrappedReject(err) {
var wrappedErr = new Error(err);
Expand All @@ -108,21 +112,21 @@ function wrapInControlFlow(flow, globalFn, fnName) {
// Without a callback, testFn can return a promise, or it will
// be assumed to have completed synchronously.
var ret = testFn();
if (webdriver.promise.isPromise(ret)) {
if (maybePromise.isPromise(ret)) {
ret.then(fulfill, wrappedReject);
} else {
fulfill(ret);
}
}
});
}, 'Run ' + fnName + description + ' in control flow').then(
callWhenIdle.bind(null, flow, done), function(err) {
callWhenIdle.bind(null, scheduler, done), function(err) {
if (!err) {
err = new Error('Unknown Error');
err.stack = '';
}
err.stack = err.stack + '\nFrom asynchronous test: \n' + driverError.stack;
callWhenIdle(flow, done.fail.bind(done, err));
callWhenIdle(scheduler, done.fail.bind(done, err));
}
);
};
Expand Down Expand Up @@ -163,33 +167,59 @@ function wrapInControlFlow(flow, globalFn, fnName) {
}

/**
* Initialize the JasmineWd adapter with a particlar webdriver instance. We
* pass webdriver here instead of using require() in order to ensure Protractor
* and Jasminews are using the same webdriver instance.
* @param {Object} flow. The ControlFlow to wrap tests in.
* Initialize the JasmineWd adapter with a particlar scheduler, generally a webdriver control flow.
*
* @param {Object=} scheduler The scheduler to wrap tests in. See scheduler.md for details.
* Defaults to a mock scheduler that calls functions immediately.
*/
function initJasmineWd(flow) {
function initJasmineWd(scheduler) {
if (jasmine.JasmineWdInitialized) {
throw Error('JasmineWd already initialized when init() was called');
}
jasmine.JasmineWdInitialized = true;

global.it = wrapInControlFlow(flow, global.it, 'it');
global.fit = wrapInControlFlow(flow, global.fit, 'fit');
global.beforeEach = wrapInControlFlow(flow, global.beforeEach, 'beforeEach');
global.afterEach = wrapInControlFlow(flow, global.afterEach, 'afterEach');
global.beforeAll = wrapInControlFlow(flow, global.beforeAll, 'beforeAll');
global.afterAll = wrapInControlFlow(flow, global.afterAll, 'afterAll');

if (flow.reset) {
// Default to mock scheduler
if (!scheduler) {
scheduler = { execute: function(fn) {
return Promise.resolve().then(fn);
} };
}

// Figure out how we're getting new promises
var newPromise;
if (typeof scheduler.promise == 'function') {
newPromise = scheduler.promise.bind(scheduler);
} else if (webdriver.promise && webdriver.promise.ControlFlow &&
(scheduler instanceof webdriver.promise.ControlFlow) &&
(webdriver.promise.USE_PROMISE_MANAGER !== false)) {
newPromise = function(resolver) {
return new webdriver.promise.Promise(resolver, scheduler);
};
} else {
newPromise = function(resolver) {
return new Promise(resolver);
};
}

// Wrap functions
global.it = wrapInScheduler(scheduler, newPromise, global.it, 'it');
global.fit = wrapInScheduler(scheduler, newPromise, global.fit, 'fit');
global.beforeEach = wrapInScheduler(scheduler, newPromise, global.beforeEach, 'beforeEach');
global.afterEach = wrapInScheduler(scheduler, newPromise, global.afterEach, 'afterEach');
global.beforeAll = wrapInScheduler(scheduler, newPromise, global.beforeAll, 'beforeAll');
global.afterAll = wrapInScheduler(scheduler, newPromise, global.afterAll, 'afterAll');

// Reset API
if (scheduler.reset) {
// On timeout, the flow should be reset. This will prevent webdriver tasks
// from overflowing into the next test and causing it to fail or timeout
// as well. This is done in the reporter instead of an afterEach block
// to ensure that it runs after any afterEach() blocks with webdriver tasks
// get to complete first.
jasmine.getEnv().addReporter(new OnTimeoutReporter(function() {
console.warn('A Jasmine spec timed out. Resetting the WebDriver Control Flow.');
flow.reset();
scheduler.reset();
}));
}
}
Expand Down
16 changes: 15 additions & 1 deletion maybePromise.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,18 @@
* or may not be promises, and code execution may or may not be synchronous.
*/


/**
* Determines if a value is a promise.
*
* @param {*} val The value to check.
* @return {boolean} true if val is a promise, false otherwise.
*/
function isPromise(val) {
return val && (typeof val.then == 'function');
}


/**
* Runs a callback synchronously against non-promise values and asynchronously
* against promises. Similar to ES6's `Promise.resolve` except that it is
Expand All @@ -25,13 +37,15 @@
* resolving to the callback's return value is returned.
*/
var maybePromise = module.exports = function maybePromise(val, callback) {
if (val && (typeof val.then == 'function')) {
if (isPromise(val)) {
return val.then(callback);
} else {
return callback(val);
}
}

maybePromise.isPromise = isPromise;

/**
* Like maybePromise() but for an array of values. Analogous to `Promise.all`.
*
Expand Down
68 changes: 68 additions & 0 deletions scheduler.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# Schedulers

Many of the core features of jasminewd are centered around automatically synchronizing your tests
with the WebDriver control flow. However, jasminewd can synchronize with any scheduler as long as
it implements the following interface:

```ts
interface Scheduler {
execute<T>(fn: () => Promise<T>|T): Promise<T>;
}
```

Where `execute` is the function used to put something on the scheduler. As long as your scheduler
implements this interface, you can pass it into `require('jasminewd2').init`.

## Custom Promise Implementation

Some schedulers need scheduled functions to use a specific implementation of the promise API. For
instance, WebDriver has its `ManagedPromise` implementation, which it needs in order to track
tasks across `then()` blocks. If your scheduler has its own promise implementation, you can
implement the following interface:

```ts
interface SchedulerWithCustomPromises {
execute<T>(fn: () => CustomPromise<T>|T): CustomPromise<T>;
promise<T>(resolver: (resolve: (T) => void, reject: (any) => void) => void): CustomPromise<T>;
}
```

If the `promise` function is specified, jasminewd will use that function to generate all of its
internal promises. If `scheduler.promise` is not specified, jasminewd will try to use WebDriver's
`ManagedPromise`. If `ManagedPromise` is not available (e.g. the control flow is disabled),
jasminewd will default to using native promises.

### Idle API

If your scheduler requires a custom promise implementation, it is highly recommended that you
implement the Idle API. This will help to mitigate issues with users who sometimes use other
promise implementations (see https://github.com/angular/jasminewd/issues/68#issuecomment-262317167).
To do this, implement the following interface:

```ts
var EventEmitter = require('events');

interface SchedulerWithIdleAPI extends EventEmitter {
execute<T>(fn: () => CustomPromise<T>|T): CustomPromise<T>;
promise<T>(resolver: (resolve: (T) => void, reject: (any) => void) => void): CustomPromise<T>;
isIdle(): boolean;
}
```

Your scheduler must emit `"idle"` when it becomes idle.


### Reset API

If you want your scheduler to be reset whenever a spec times out, implement the following interface:

```ts
interface SchedulerWithResetAPI {
execute<T>(fn: () => CustomPromise<T>|T): CustomPromise<T>;
reset(): void;
}
```

jasminewd will automatically look for a `reset` function and call it when specs time out. This is
useful so that if a spec executes a task that hangs, only that spec will timeout (as opposed to
tying up the scheduler and causing all future specs to timeout).
22 changes: 21 additions & 1 deletion scripts/test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,27 @@ echo "result: $results_line"

export SELENIUM_PROMISE_MANAGER=0

echo "### running async/await passing specs"
echo "### running async/await passing specs with control flow disabled"
CMD=$CMD_BASE$NO_CF_PASSING_SPECS
echo "### $CMD"
$CMD
[ "$?" -eq 0 ] || exit 1
echo

EXPECTED_RESULTS="19 specs, 17 failures"
echo "### running async/await failing specs (expecting $EXPECTED_RESULTS)"
CMD=$CMD_BASE$NO_CF_FAILING_SPECS
echo "### $CMD"
res=`$CMD 2>/dev/null`
results_line=`echo "$res" | tail -2 | head -1`
echo "result: $results_line"
[ "$results_line" = "$EXPECTED_RESULTS" ] || exit 1

# Run only the async/await tests with no scheduler

export JASMINEWD_TESTS_NO_SCHEDULER=1

echo "### running async/await passing specs with no scheduler"
CMD=$CMD_BASE$NO_CF_PASSING_SPECS
echo "### $CMD"
$CMD
Expand Down
2 changes: 1 addition & 1 deletion spec/common.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {promise as wdpromise, WebElement} from 'selenium-webdriver';

const flow = wdpromise.controlFlow();
require('../index.js').init(flow);
require('../index.js').init(process.env['JASMINEWD_TESTS_NO_SCHEDULER'] ? null : flow);

export function getFakeDriver() {
return {
Expand Down
11 changes: 11 additions & 0 deletions spec/maybePromiseSpec.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,17 @@ describe('maybePromise', function() {
return promise;
}

it('should be able to tell promises from non-promises', function() {
expect(maybePromise.isPromise(num)).toBe(false);
expect(maybePromise.isPromise(str)).toBe(false);
expect(maybePromise.isPromise(obj)).toBe(false);
expect(maybePromise.isPromise(idFun)).toBe(false);
expect(maybePromise.isPromise(promiseMe(num))).toBe(true);
expect(maybePromise.isPromise(promiseMe(str))).toBe(true);
expect(maybePromise.isPromise(promiseMe(obj))).toBe(true);
expect(maybePromise.isPromise(promiseMe(idFun))).toBe(true);
});

describe('singletons', function() {
it('should be able to use non-promises', function(done) {
maybePromise(num, function(n) {
Expand Down

0 comments on commit 8c04766

Please sign in to comment.