diff --git a/karma.conf.js b/karma.conf.js index 89437c203a..eed6d580fa 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -1,6 +1,7 @@ // karma.conf.js - the config file for karma, which runs our tests. var path = require('path'); +var fs = require('fs'); /* * We use webpack to build our tests. It's a pain to have to wait for webpack @@ -17,6 +18,21 @@ var path = require('path'); process.env.PHANTOMJS_BIN = 'node_modules/.bin/phantomjs'; +function fileExists(name) { + try { + fs.statSync(gsCss); + return true; + } catch (e) { + return false; + } +} + +// try find the gemini-scrollbar css in an npm-version-agnostic way +var gsCss = 'node_modules/gemini-scrollbar/gemini-scrollbar.css'; +if (!fileExists(gsCss)) { + gsCss = 'node_modules/react-gemini-scrollbar/'+gsCss; +} + module.exports = function (config) { config.set({ // frameworks to use @@ -26,6 +42,7 @@ module.exports = function (config) { // list of files / patterns to load in the browser files: [ 'test/tests.js', + gsCss, ], // list of files to exclude diff --git a/src/KeyCode.js b/src/KeyCode.js new file mode 100644 index 0000000000..b80703d39e --- /dev/null +++ b/src/KeyCode.js @@ -0,0 +1,32 @@ +/* +Copyright 2016 OpenMarket Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/* a selection of key codes, as used in KeyboardEvent.keyCode */ +module.exports = { + BACKSPACE: 8, + TAB: 9, + ENTER: 13, + SHIFT: 16, + PAGE_UP: 33, + PAGE_DOWN: 34, + END: 35, + HOME: 36, + LEFT: 37, + UP: 38, + RIGHT: 39, + DOWN: 40, + DELETE: 46, +}; diff --git a/src/components/structures/MatrixChat.js b/src/components/structures/MatrixChat.js index 4ee45c2034..497251e5aa 100644 --- a/src/components/structures/MatrixChat.js +++ b/src/components/structures/MatrixChat.js @@ -35,6 +35,7 @@ var Tinter = require("../../Tinter"); var sdk = require('../../index'); var MatrixTools = require('../../MatrixTools'); var linkifyMatrix = require("../../linkify-matrix"); +var KeyCode = require('../../KeyCode'); module.exports = React.createClass({ displayName: 'MatrixChat', @@ -722,11 +723,10 @@ module.exports = React.createClass({ }, onKeyDown: function(ev) { - if (ev.altKey) { /* // Remove this for now as ctrl+alt = alt-gr so this breaks keyboards which rely on alt-gr for numbers // Will need to find a better meta key if anyone actually cares about using this. - if (ev.ctrlKey && ev.keyCode > 48 && ev.keyCode < 58) { + if (ev.altKey && ev.ctrlKey && ev.keyCode > 48 && ev.keyCode < 58) { dis.dispatch({ action: 'view_indexed_room', roomIndex: ev.keyCode - 49, @@ -736,18 +736,45 @@ module.exports = React.createClass({ return; } */ - switch (ev.keyCode) { - case 38: - dis.dispatch({action: 'view_prev_room'}); - ev.stopPropagation(); - ev.preventDefault(); - break; - case 40: - dis.dispatch({action: 'view_next_room'}); - ev.stopPropagation(); - ev.preventDefault(); - break; - } + + var handled = false; + + switch (ev.keyCode) { + case KeyCode.UP: + case KeyCode.DOWN: + if (ev.altKey) { + var action = ev.keyCode == KeyCode.UP ? + 'view_prev_room' : 'view_next_room'; + dis.dispatch({action: action}); + handled = true; + } + break; + + case KeyCode.PAGE_UP: + case KeyCode.PAGE_DOWN: + this._onScrollKeyPressed(ev); + handled = true; + break; + + case KeyCode.HOME: + case KeyCode.END: + if (ev.ctrlKey) { + this._onScrollKeyPressed(ev); + handled = true; + } + break; + } + + if (handled) { + ev.stopPropagation(); + ev.preventDefault(); + } + }, + + /** dispatch a page-up/page-down/etc to the appropriate component */ + _onScrollKeyPressed(ev) { + if (this.refs.roomView) { + this.refs.roomView.handleScrollKey(ev); } }, diff --git a/src/components/structures/MessagePanel.js b/src/components/structures/MessagePanel.js index dff3e9e514..69f622e0d4 100644 --- a/src/components/structures/MessagePanel.js +++ b/src/components/structures/MessagePanel.js @@ -132,6 +132,14 @@ module.exports = React.createClass({ } }, + /* jump to the top of the content. + */ + scrollToTop: function() { + if (this.refs.scrollPanel) { + this.refs.scrollPanel.scrollToTop(); + } + }, + /* jump to the bottom of the content. */ scrollToBottom: function() { @@ -139,6 +147,26 @@ module.exports = React.createClass({ this.refs.scrollPanel.scrollToBottom(); } }, + + /** + * Page up/down. + * + * mult: -1 to page up, +1 to page down + */ + scrollRelative: function(mult) { + if (this.refs.scrollPanel) { + this.refs.scrollPanel.scrollRelative(mult); + } + }, + + /** + * Scroll up/down in response to a scroll key + */ + handleScrollKey: function(ev) { + if (this.refs.scrollPanel) { + this.refs.scrollPanel.handleScrollKey(ev); + } + }, /* jump to the given event id. * @@ -205,6 +233,13 @@ module.exports = React.createClass({ // assume there is no read marker until proven otherwise var readMarkerVisible = false; + // if the readmarker has moved, cancel any active ghost. + if (this.currentReadMarkerEventId && this.props.readMarkerEventId && + this.props.readMarkerVisible && + this.currentReadMarkerEventId != this.props.readMarkerEventId) { + this.currentGhostEventId = null; + } + for (i = 0; i < this.props.events.length; i++) { var mxEv = this.props.events[i]; var wantTile = true; @@ -337,21 +372,16 @@ module.exports = React.createClass({ ); }, - _getReadMarkerGhostTile: function() { - // reset the ghostEventId when the animation finishes, so that - // we can make a new one (and so that we don't run the - // animation code every time we render) - var completeFunc = () => { - this.currentGhostEventId = null; - }; + _startAnimation: function(ghostNode) { + Velocity(ghostNode, {opacity: '0', width: '10%'}, + {duration: 400, easing: 'easeInSine', + delay: 1000}); + }, + _getReadMarkerGhostTile: function() { var hr =
; // give it a key which depends on the event id. That will ensure that diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index 7fdb9f437a..c523042248 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -1115,6 +1115,24 @@ module.exports = React.createClass({ } }, + /** + * called by the parent component when PageUp/Down/etc is pressed. + * + * We pass it down to the scroll panel. + */ + handleScrollKey: function(ev) { + var panel; + if(this.refs.searchResultsPanel) { + panel = this.refs.searchResultsPanel; + } else if(this.refs.messagePanel) { + panel = this.refs.messagePanel; + } + + if(panel) { + panel.handleScrollKey(ev); + } + }, + // this has to be a proper method rather than an unnamed function, // otherwise react calls it with null on each update. _gatherTimelinePanelRef: function(r) { diff --git a/src/components/structures/ScrollPanel.js b/src/components/structures/ScrollPanel.js index ddec81c107..e62b67e314 100644 --- a/src/components/structures/ScrollPanel.js +++ b/src/components/structures/ScrollPanel.js @@ -18,8 +18,10 @@ var React = require("react"); var ReactDOM = require("react-dom"); var GeminiScrollbar = require('react-gemini-scrollbar'); var q = require("q"); +var KeyCode = require('../../KeyCode'); var DEBUG_SCROLL = false; +// var DEBUG_SCROLL = true; if (DEBUG_SCROLL) { // using bind means that we get to keep useful line numbers in the console @@ -144,7 +146,8 @@ module.exports = React.createClass({ onScroll: function(ev) { var sn = this._getScrollNode(); - debuglog("Scroll event: offset now:", sn.scrollTop, "recentEventScroll:", this.recentEventScroll); + debuglog("Scroll event: offset now:", sn.scrollTop, + "_lastSetScroll:", this._lastSetScroll); // Sometimes we see attempts to write to scrollTop essentially being // ignored. (Or rather, it is successfully written, but on the next @@ -158,13 +161,10 @@ module.exports = React.createClass({ // By way of a workaround, we detect this situation and just keep // resetting scrollTop until we see the scroll node have the right // value. - if (this.recentEventScroll !== undefined) { - if(sn.scrollTop < this.recentEventScroll-200) { - console.log("Working around vector-im/vector-web#528"); - this._restoreSavedScrollState(); - return; - } - this.recentEventScroll = undefined; + if (this._lastSetScroll !== undefined && sn.scrollTop < this._lastSetScroll-200) { + console.log("Working around vector-im/vector-web#528"); + this._restoreSavedScrollState(); + return; } // If there weren't enough children to fill the viewport, the scroll we @@ -327,6 +327,17 @@ module.exports = React.createClass({ this.scrollState = {stuckAtBottom: true}; }, + /** + * jump to the top of the content. + */ + scrollToTop: function() { + this._setScrollTop(0); + this._saveScrollState(); + }, + + /** + * jump to the bottom of the content. + */ scrollToBottom: function() { // the easiest way to make sure that the scroll state is correctly // saved is to do the scroll, then save the updated state. (Calculating @@ -336,6 +347,45 @@ module.exports = React.createClass({ this._saveScrollState(); }, + /** + * Page up/down. + * + * mult: -1 to page up, +1 to page down + */ + scrollRelative: function(mult) { + var scrollNode = this._getScrollNode(); + var delta = mult * scrollNode.clientHeight * 0.5; + this._setScrollTop(scrollNode.scrollTop + delta); + this._saveScrollState(); + }, + + /** + * Scroll up/down in response to a scroll key + */ + handleScrollKey: function(ev) { + switch (ev.keyCode) { + case KeyCode.PAGE_UP: + this.scrollRelative(-1); + break; + + case KeyCode.PAGE_DOWN: + this.scrollRelative(1); + break; + + case KeyCode.HOME: + if (ev.ctrlKey) { + this.scrollToTop(); + } + break; + + case KeyCode.END: + if (ev.ctrlKey) { + this.scrollToBottom(); + } + break; + } + }, + /* Scroll the panel to bring the DOM node with the scroll token * `scrollToken` into view. * @@ -395,17 +445,14 @@ module.exports = React.createClass({ var wrapperRect = ReactDOM.findDOMNode(this).getBoundingClientRect(); var boundingRect = node.getBoundingClientRect(); var scrollDelta = boundingRect.bottom + pixelOffset - wrapperRect.bottom; + + debuglog("Scrolling to token '" + node.dataset.scrollToken + "'+" + + pixelOffset + " (delta: "+scrollDelta+")"); + if(scrollDelta != 0) { this._setScrollTop(scrollNode.scrollTop + scrollDelta); - - // see the comments in onScroll regarding recentEventScroll - this.recentEventScroll = scrollNode.scrollTop; } - debuglog("Scrolled to token", node.dataset.scrollToken, "+", - pixelOffset+":", scrollNode.scrollTop, - "(delta: "+scrollDelta+")"); - debuglog("recentEventScroll now "+this.recentEventScroll); }, _saveScrollState: function() { diff --git a/src/components/structures/TimelinePanel.js b/src/components/structures/TimelinePanel.js index be2667201e..12931fed37 100644 --- a/src/components/structures/TimelinePanel.js +++ b/src/components/structures/TimelinePanel.js @@ -27,6 +27,7 @@ var dis = require("../../dispatcher"); var ObjectUtils = require('../../ObjectUtils'); var Modal = require("../../Modal"); var UserActivity = require("../../UserActivity"); +var KeyCode = require('../../KeyCode'); var PAGINATE_SIZE = 20; var INITIAL_SIZE = 20; @@ -520,6 +521,23 @@ var TimelinePanel = React.createClass({ return null; }, + /** + * called by the parent component when PageUp/Down/etc is pressed. + * + * We pass it down to the scroll panel. + */ + handleScrollKey: function(ev) { + if (!this.refs.messagePanel) { return; } + + // jump to the live timeline on ctrl-end, rather than the end of the + // timeline window. + if (ev.ctrlKey && ev.keyCode == KeyCode.END) { + this.jumpToLiveTimeline(); + } else { + this.refs.messagePanel.handleScrollKey(ev); + } + }, + _initTimeline: function(props) { var initialEvent = props.eventId; var pixelOffset = props.eventPixelOffset; diff --git a/src/components/structures/login/ForgotPassword.js b/src/components/structures/login/ForgotPassword.js index c457ce1ead..feb4ee96b1 100644 --- a/src/components/structures/login/ForgotPassword.js +++ b/src/components/structures/login/ForgotPassword.js @@ -38,8 +38,8 @@ module.exports = React.createClass({ getInitialState: function() { return { - enteredHomeserverUrl: this.props.homeserverUrl, - enteredIdentityServerUrl: this.props.identityServerUrl, + enteredHomeserverUrl: this.props.customHsUrl || this.props.defaultHsUrl, + enteredIdentityServerUrl: this.props.customIsUrl || this.props.defaultIsUrl, progress: null }; }, diff --git a/src/components/views/rooms/MessageComposerInput.js b/src/components/views/rooms/MessageComposerInput.js index 1aa2beb41c..733d9e6056 100644 --- a/src/components/views/rooms/MessageComposerInput.js +++ b/src/components/views/rooms/MessageComposerInput.js @@ -34,15 +34,7 @@ var MemberEntry = require("../../../TabCompleteEntries").MemberEntry; var sdk = require('../../../index'); var dis = require("../../../dispatcher"); -var KeyCode = { - ENTER: 13, - BACKSPACE: 8, - DELETE: 46, - TAB: 9, - SHIFT: 16, - UP: 38, - DOWN: 40 -}; +var KeyCode = require("../../../KeyCode"); var TYPING_USER_TIMEOUT = 10000; var TYPING_SERVER_TIMEOUT = 30000; diff --git a/test/components/structures/MessagePanel-test.js b/test/components/structures/MessagePanel-test.js index f775388617..9a4996b5da 100644 --- a/test/components/structures/MessagePanel-test.js +++ b/test/components/structures/MessagePanel-test.js @@ -130,4 +130,50 @@ describe('MessagePanel', function () { }, 100); }, 100); }); + + it('shows only one ghost when the RM moves twice', function() { + var parentDiv = document.createElement('div'); + + // first render with the RM in one place + var mp = ReactDOM.render( + , parentDiv); + + var tiles = TestUtils.scryRenderedComponentsWithType( + mp, sdk.getComponent('rooms.EventTile')); + var tileContainers = tiles.map(function (t) { + return ReactDOM.findDOMNode(t).parentNode; + }); + + // now move the RM + mp = ReactDOM.render( + , parentDiv); + + // now there should be two RM containers + var found = TestUtils.scryRenderedDOMComponentsWithClass(mp, 'mx_RoomView_myReadMarker_container'); + expect(found.length).toEqual(2); + + // the first should be the ghost + expect(tileContainers.indexOf(found[0].previousSibling)).toEqual(4); + + // the second should be the real RM + expect(tileContainers.indexOf(found[1].previousSibling)).toEqual(6); + + // and move the RM again + mp = ReactDOM.render( + , parentDiv); + + // still two RM containers + found = TestUtils.scryRenderedDOMComponentsWithClass(mp, 'mx_RoomView_myReadMarker_container'); + expect(found.length).toEqual(2); + + // they should have moved + expect(tileContainers.indexOf(found[0].previousSibling)).toEqual(6); + expect(tileContainers.indexOf(found[1].previousSibling)).toEqual(8); + }); }); diff --git a/test/components/structures/ScrollPanel-test.js b/test/components/structures/ScrollPanel-test.js new file mode 100644 index 0000000000..13721c9ecd --- /dev/null +++ b/test/components/structures/ScrollPanel-test.js @@ -0,0 +1,275 @@ +/* +Copyright 2016 OpenMarket Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +var React = require('react'); +var ReactDOM = require("react-dom"); +var ReactTestUtils = require('react-addons-test-utils'); +var expect = require('expect'); +var q = require('q'); + +var sdk = require('matrix-react-sdk'); + +var ScrollPanel = sdk.getComponent('structures.ScrollPanel'); +var test_utils = require('test-utils'); + +var Tester = React.createClass({ + getInitialState: function() { + return { + tileKeys: [], + }; + }, + + componentWillMount: function() { + this.fillCounts = {'b': 0, 'f': 0}; + this._fillHandlers = {'b': null, 'f': null}; + this._fillDefers = {'b': null, 'f': null}; + this._scrollDefer = null; + + // scrollTop at the last scroll event + this.lastScrollEvent = null; + }, + + _onFillRequest: function(back) { + var dir = back ? 'b': 'f'; + console.log("FillRequest: " + dir); + this.fillCounts[dir]++; + + var handler = this._fillHandlers[dir]; + var defer = this._fillDefers[dir]; + + // don't use the same handler twice + this._fillHandlers[dir] = null; + this._fillDefers[dir] = null; + + var res; + if (handler) { + res = handler(); + } else { + res = q(false); + } + + if (defer) { + defer.resolve(); + } + return res; + }, + + addFillHandler: function(dir, handler) { + this._fillHandlers[dir] = handler; + }, + + /* returns a promise which will resolve when the fill happens */ + awaitFill: function(dir) { + var defer = q.defer(); + this._fillDefers[dir] = defer; + return defer.promise; + }, + + _onScroll: function(ev) { + var st = ev.target.scrollTop; + console.log("Scroll event; scrollTop: " + st); + this.lastScrollEvent = st; + + var d = this._scrollDefer; + if (d) { + this._scrollDefer = null; + d.resolve(); + } + }, + + /* returns a promise which will resolve when a scroll event happens */ + awaitScroll: function() { + console.log("Awaiting scroll"); + this._scrollDefer = q.defer(); + return this._scrollDefer.promise; + }, + + setTileKeys: function(keys) { + console.log("Updating keys: len=" + keys.length); + this.setState({tileKeys: keys.slice()}); + }, + + scrollPanel: function() { + return this.refs.sp; + }, + + _mkTile: function(key) { + // each tile is 150 pixels high: + // 98 pixels of body + // 2 pixels of border + // 50 pixels of margin + // + // there is an extra 50 pixels of margin at the bottom. + return ( +
  • +
    + {key} +
    +
  • + ); + }, + + render: function() { + var tiles = this.state.tileKeys.map(this._mkTile); + console.log("rendering with " + tiles.length + " tiles"); + return ( + + {tiles} + + ); + }, +}); + +describe('ScrollPanel', function() { + var parentDiv; + var tester; + var scrollingDiv; + + beforeEach(function(done) { + test_utils.beforeEach(this); + + // create a div of a useful size to put our panel in, and attach it to + // the document so that we can interact with it properly. + parentDiv = document.createElement('div'); + parentDiv.style.width = '800px'; + parentDiv.style.height = '600px'; + parentDiv.style.overflow = 'hidden'; + document.body.appendChild(parentDiv); + + tester = ReactDOM.render(, parentDiv); + expect(tester.fillCounts.b).toEqual(1); + expect(tester.fillCounts.f).toEqual(1); + + scrollingDiv = ReactTestUtils.findRenderedDOMComponentWithClass( + tester, "gm-scroll-view"); + + // wait for a browser tick to let the initial paginates complete + setTimeout(function() { + done(); + }, 0); + }); + + afterEach(function() { + if (parentDiv) { + document.body.removeChild(parentDiv); + parentDiv = null; + } + }); + + it('should handle scrollEvent strangeness', function(done) { + var events = []; + + q().then(() => { + // initialise with a few events + for (var i = 0; i < 10; i++) { + events.push(i+90); + } + tester.setTileKeys(events); + expect(tester.fillCounts.b).toEqual(1); + expect(tester.fillCounts.f).toEqual(2); + expect(scrollingDiv.scrollHeight).toEqual(1550) // 10*150 + 50 + expect(scrollingDiv.scrollTop).toEqual(1550 - 600); + return tester.awaitScroll(); + }).then(() => { + expect(tester.lastScrollEvent).toBe(950); + + // we want to simulate back-filling as we scroll up + tester.addFillHandler('b', function() { + var newEvents = []; + for (var i = 0; i < 10; i++) { + newEvents.push(i+80); + } + events.unshift.apply(events, newEvents); + tester.setTileKeys(events); + return q(true); + }); + + // simulate scrolling up; this should trigger the backfill + scrollingDiv.scrollTop = 200; + + return tester.awaitFill('b'); + }).then(() => { + console.log('filled'); + + // at this point, ScrollPanel will have updated scrollTop, but + // the event hasn't fired. Stamp over the scrollTop. + expect(tester.lastScrollEvent).toEqual(200); + expect(scrollingDiv.scrollTop).toEqual(10*150 + 200); + scrollingDiv.scrollTop = 500; + + return tester.awaitScroll(); + }).then(() => { + expect(tester.lastScrollEvent).toBe(10*150 + 200); + expect(scrollingDiv.scrollTop).toEqual(10*150 + 200); + }).done(done); + }); + + it('should not get stuck in #528 workaround', function(done) { + var events = []; + q().then(() => { + // initialise with a bunch of events + for (var i = 0; i < 40; i++) { + events.push(i); + } + tester.setTileKeys(events); + expect(tester.fillCounts.b).toEqual(1); + expect(tester.fillCounts.f).toEqual(2); + expect(scrollingDiv.scrollHeight).toEqual(6050) // 40*150 + 50 + expect(scrollingDiv.scrollTop).toEqual(6050 - 600); + + // try to scroll up, to a non-integer offset. + tester.scrollPanel().scrollToToken("30", -101/3); + + expect(scrollingDiv.scrollTop).toEqual(4616); // 31*150 - 34 + + // wait for the scroll event to land + return tester.awaitScroll(); // fails + }).then(() => { + expect(tester.lastScrollEvent).toEqual(4616); + + // Now one more event; this will make it reset the scroll, but + // because the delta will be less than 1, will not trigger a + // scroll event, this leaving recentEventScroll defined. + console.log("Adding event 50"); + events.push(50); + tester.setTileKeys(events); + + // wait for the scrollpanel to stop trying to paginate + }).then(() => { + // Now, simulate hitting "scroll to bottom". + events = []; + for (var i = 100; i < 120; i++) { + events.push(i); + } + tester.setTileKeys(events); + tester.scrollPanel().scrollToBottom(); + + // wait for the scroll event to land + return tester.awaitScroll(); // fails + }).then(() => { + expect(scrollingDiv.scrollTop).toEqual(20*150 + 50 - 600); + + // simulate a user-initiated scroll on the div + scrollingDiv.scrollTop = 1200; + return tester.awaitScroll(); + }).then(() => { + expect(scrollingDiv.scrollTop).toEqual(1200); + }).done(done); + }); +}); diff --git a/test/test-utils.js b/test/test-utils.js index 956ced0554..ed14306fbe 100644 --- a/test/test-utils.js +++ b/test/test-utils.js @@ -5,6 +5,18 @@ var jssdk = require('matrix-js-sdk'); var MatrixEvent = jssdk.MatrixEvent; var sinon = require('sinon'); +/** + * Perform common actions before each test case, e.g. printing the test case + * name to stdout. + * @param {Mocha.Context} context The test context + */ +module.exports.beforeEach = function(context) { + var desc = context.currentTest.fullTitle(); + console.log(); + console.log(desc); + console.log(new Array(1 + desc.length).join("=")); +}; + /** * Stub out the MatrixClient, and configure the MatrixClientPeg object to @@ -128,22 +140,3 @@ module.exports.mkMessage = function(opts) { }; return module.exports.mkEvent(opts); }; - -/** - * make the test fail, with the given exception - * - *

    This is useful for use with integration tests which use asyncronous - * methods: it can be added as a 'catch' handler in a promise chain. - * - * @param {Error} error exception to be reported - * - * @example - * it("should not throw", function(done) { - * asynchronousMethod().then(function() { - * // some tests - * }).catch(utils.failTest).done(done); - * }); - */ -module.exports.failTest = function(error) { - expect(error.stack).toBe(null); -};