diff --git a/karma.conf.js b/karma.conf.js index 7a8831635b..e59a6c9e15 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -1,6 +1,5 @@ // karma.conf.js - the config file for karma, which runs our tests. -var webpack = require('webpack'); var path = require('path'); /* @@ -8,6 +7,10 @@ var path = require('path'); * to build everything; however it's the easiest way to load our dependencies * from node_modules. * + * If you run karma in multi-run mode (with `npm run test-multi`), it will watch + * the tests for changes, and webpack will rebuild using a cache. This is much quicker + * than a clean rebuild. + * * TODO: * - can we run one test at a time? */ @@ -26,11 +29,18 @@ module.exports = function (config) { ], // list of files to exclude - // (this doesn't work, and I don't know why - we still rerun the tests - // when lockfiles are created) - exclude: [ - '**/.#*' - ], + // + // This doesn't work. It turns out that it's webpack which does the + // watching of the /test directory (possibly karma only watches + // tests.js itself). Webpack watches the directory so that it can spot + // new tests, which is fair enough; unfortunately it triggers a rebuild + // every time a lockfile is created in that directory, and there + // doesn't seem to be any way to tell webpack to ignore particular + // files in a watched directory. + // + // exclude: [ + // '**/.#*' + // ], // preprocess matching files before serving them to the browser // available preprocessors: @@ -83,13 +93,6 @@ module.exports = function (config) { module: { loaders: [ { test: /\.json$/, loader: "json" }, - { - // disable 'require' and 'define' for sinon, per - // https://github.com/webpack/webpack/issues/304#issuecomment-170883329 - test: /sinon\/pkg\/sinon\.js/, - // TODO: use 'query'? - loader: 'imports?define=>false,require=>false', - }, { test: /\.js$/, loader: "babel", include: [path.resolve('./src'), @@ -107,6 +110,11 @@ module.exports = function (config) { // there is no need for webpack to parse them - they can // just be included as-is. /highlight\.js\/lib\/languages/, + + // also disable parsing for sinon, because it + // tries to do voodoo with 'require' which upsets + // webpack (https://github.com/webpack/webpack/issues/304) + /sinon\/pkg\/sinon\.js$/, ], }, resolve: { @@ -114,6 +122,10 @@ module.exports = function (config) { 'matrix-react-sdk': path.resolve('src/index.js'), 'sinon': 'sinon/pkg/sinon.js', }, + root: [ + path.resolve('./src'), + path.resolve('./test'), + ], }, devtool: 'inline-source-map', }, diff --git a/package.json b/package.json index 8041d4154d..7c4bd714ef 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,6 @@ "babel-preset-react": "^6.5.0", "babel-runtime": "^6.6.1", "expect": "^1.16.0", - "imports-loader": "^0.6.5", "json-loader": "^0.5.3", "karma": "^0.13.22", "karma-chrome-launcher": "^0.2.3", diff --git a/test/components/structures/MessagePanel-test.js b/test/components/structures/MessagePanel-test.js new file mode 100644 index 0000000000..37b79a8f0a --- /dev/null +++ b/test/components/structures/MessagePanel-test.js @@ -0,0 +1,139 @@ +/* +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 TestUtils = require('react-addons-test-utils'); +var expect = require('expect'); + +var sdk = require('matrix-react-sdk'); + +var MessagePanel = sdk.getComponent('structures.MessagePanel'); + +var test_utils = require('test-utils'); +var mockclock = require('mock-clock'); + +describe('MessagePanel', function () { + var clock = mockclock.clock(); + var realSetTimeout = window.setTimeout; + var events = mkEvents(); + + afterEach(function () { + clock.uninstall(); + }); + + function mkEvents() { + var events = []; + var ts0 = Date.now(); + for (var i = 0; i < 10; i++) { + events.push(test_utils.mkMessage( + { + event: true, room: "!room:id", user: "@user:id", + ts: ts0 + i*1000, + })); + } + return events; + } + + it('should show the events', function() { + var res = TestUtils.renderIntoDocument( + + ); + + // just check we have the right number of tiles for now + var tiles = TestUtils.scryRenderedComponentsWithType( + res, sdk.getComponent('rooms.EventTile')); + expect(tiles.length).toEqual(10); + }); + + it('should show the read-marker in the right place', function() { + var res = TestUtils.renderIntoDocument( + + ); + + var tiles = TestUtils.scryRenderedComponentsWithType( + res, sdk.getComponent('rooms.EventTile')); + + // find the
  • which wraps the read marker + var rm = TestUtils.findRenderedDOMComponentWithClass(res, 'mx_RoomView_myReadMarker_container'); + + // it should follow the
  • which wraps the event tile for event 4 + var eventContainer = ReactDOM.findDOMNode(tiles[4]).parentNode; + expect(rm.previousSibling).toEqual(eventContainer); + }); + + it('shows a ghost read-marker when the read-marker moves', function(done) { + // fake the clock so that we can test the velocity animation. + clock.install(); + clock.mockDate(); + + 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')); + + // find the
  • which wraps the read marker + var rm = TestUtils.findRenderedDOMComponentWithClass(mp, 'mx_RoomView_myReadMarker_container'); + var eventContainer = ReactDOM.findDOMNode(tiles[4]).parentNode; + expect(rm.previousSibling).toEqual(eventContainer); + + // 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 + var ghost = found[0]; + eventContainer = ReactDOM.findDOMNode(tiles[4]).parentNode; + expect(ghost.previousSibling).toEqual(eventContainer); + var hr = ghost.children[0]; + console.log("Opacity:", hr.style.opacity); + + // the first should be the ghost + eventContainer = ReactDOM.findDOMNode(tiles[4]).parentNode; + expect(found[0].previousSibling).toEqual(eventContainer); + + // the second should be the real thing + eventContainer = ReactDOM.findDOMNode(tiles[4]).parentNode; + expect(ghost.previousSibling).toEqual(eventContainer); + + // advance the clock, and then let the browser run an animation frame, + // to let the animation start + clock.tick(1500); + + realSetTimeout(() => { + // then advance it again to let it complete + clock.tick(1000); + realSetTimeout(() => { + // the ghost should now have finished + expect(hr.style.opacity).toEqual(0); + done(); + }, 100); + }, 100); + }); +}); diff --git a/test/components/stub-component.js b/test/components/stub-component.js index 8882a0b35d..ba37850ec6 100644 --- a/test/components/stub-component.js +++ b/test/components/stub-component.js @@ -4,8 +4,17 @@ var React = require('react'); -module.exports = React.createClass({ - render: function() { - return
    ; - }, -}); +module.exports = function(opts) { + opts = opts || {}; + if (!opts.displayName) { + opts.displayName = 'StubComponent'; + } + + if (!opts.render) { + opts.render = function() { + return
    {this.displayName}
    ; + } + } + + return React.createClass(opts); +}; diff --git a/test/mock-clock.js b/test/mock-clock.js new file mode 100644 index 0000000000..a99b01a0fb --- /dev/null +++ b/test/mock-clock.js @@ -0,0 +1,420 @@ +/* +Copyright (c) 2008-2015 Pivotal Labs + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +*/ + +/* This is jasmine's implementation of a mock clock, lifted from the depths of + * jasmine-core and exposed as a standalone module. The interface is just the + * same as that of jasmine.clock. For example: + * + * var mock_clock = require("mock-clock").clock(); + * mock_clock.install(); + * setTimeout(function() { + * timerCallback(); + * }, 100); + * + * expect(timerCallback).not.toHaveBeenCalled(); + * mock_clock.tick(101); + * expect(timerCallback).toHaveBeenCalled(); + * + * mock_clock.uninstall(); + * + * + * The reason for C&Ping jasmine's clock here is that jasmine itself is + * difficult to webpack, and we don't really want all of it. Sinon also has a + * mock-clock implementation, but again, it is difficult to webpack. + */ + +var j$ = {}; + +j$.Clock = function () { + function Clock(global, delayedFunctionSchedulerFactory, mockDate) { + var self = this, + realTimingFunctions = { + setTimeout: global.setTimeout, + clearTimeout: global.clearTimeout, + setInterval: global.setInterval, + clearInterval: global.clearInterval + }, + fakeTimingFunctions = { + setTimeout: setTimeout, + clearTimeout: clearTimeout, + setInterval: setInterval, + clearInterval: clearInterval + }, + installed = false, + delayedFunctionScheduler, + timer; + + + self.install = function() { + if(!originalTimingFunctionsIntact()) { + throw new Error('Jasmine Clock was unable to install over custom global timer functions. Is the clock already installed?'); + } + replace(global, fakeTimingFunctions); + timer = fakeTimingFunctions; + delayedFunctionScheduler = delayedFunctionSchedulerFactory(); + installed = true; + + return self; + }; + + self.uninstall = function() { + delayedFunctionScheduler = null; + mockDate.uninstall(); + replace(global, realTimingFunctions); + + timer = realTimingFunctions; + installed = false; + }; + + self.withMock = function(closure) { + this.install(); + try { + closure(); + } finally { + this.uninstall(); + } + }; + + self.mockDate = function(initialDate) { + mockDate.install(initialDate); + }; + + self.setTimeout = function(fn, delay, params) { + if (legacyIE()) { + if (arguments.length > 2) { + throw new Error('IE < 9 cannot support extra params to setTimeout without a polyfill'); + } + return timer.setTimeout(fn, delay); + } + return Function.prototype.apply.apply(timer.setTimeout, [global, arguments]); + }; + + self.setInterval = function(fn, delay, params) { + if (legacyIE()) { + if (arguments.length > 2) { + throw new Error('IE < 9 cannot support extra params to setInterval without a polyfill'); + } + return timer.setInterval(fn, delay); + } + return Function.prototype.apply.apply(timer.setInterval, [global, arguments]); + }; + + self.clearTimeout = function(id) { + return Function.prototype.call.apply(timer.clearTimeout, [global, id]); + }; + + self.clearInterval = function(id) { + return Function.prototype.call.apply(timer.clearInterval, [global, id]); + }; + + self.tick = function(millis) { + if (installed) { + mockDate.tick(millis); + delayedFunctionScheduler.tick(millis); + } else { + throw new Error('Mock clock is not installed, use jasmine.clock().install()'); + } + }; + + return self; + + function originalTimingFunctionsIntact() { + return global.setTimeout === realTimingFunctions.setTimeout && + global.clearTimeout === realTimingFunctions.clearTimeout && + global.setInterval === realTimingFunctions.setInterval && + global.clearInterval === realTimingFunctions.clearInterval; + } + + function legacyIE() { + //if these methods are polyfilled, apply will be present + return !(realTimingFunctions.setTimeout || realTimingFunctions.setInterval).apply; + } + + function replace(dest, source) { + for (var prop in source) { + dest[prop] = source[prop]; + } + } + + function setTimeout(fn, delay) { + return delayedFunctionScheduler.scheduleFunction(fn, delay, argSlice(arguments, 2)); + } + + function clearTimeout(id) { + return delayedFunctionScheduler.removeFunctionWithId(id); + } + + function setInterval(fn, interval) { + return delayedFunctionScheduler.scheduleFunction(fn, interval, argSlice(arguments, 2), true); + } + + function clearInterval(id) { + return delayedFunctionScheduler.removeFunctionWithId(id); + } + + function argSlice(argsObj, n) { + return Array.prototype.slice.call(argsObj, n); + } + } + + return Clock; +}(); + + +j$.DelayedFunctionScheduler = function() { + function DelayedFunctionScheduler() { + var self = this; + var scheduledLookup = []; + var scheduledFunctions = {}; + var currentTime = 0; + var delayedFnCount = 0; + + self.tick = function(millis) { + millis = millis || 0; + var endTime = currentTime + millis; + + runScheduledFunctions(endTime); + currentTime = endTime; + }; + + self.scheduleFunction = function(funcToCall, millis, params, recurring, timeoutKey, runAtMillis) { + var f; + if (typeof(funcToCall) === 'string') { + /* jshint evil: true */ + f = function() { return eval(funcToCall); }; + /* jshint evil: false */ + } else { + f = funcToCall; + } + + millis = millis || 0; + timeoutKey = timeoutKey || ++delayedFnCount; + runAtMillis = runAtMillis || (currentTime + millis); + + var funcToSchedule = { + runAtMillis: runAtMillis, + funcToCall: f, + recurring: recurring, + params: params, + timeoutKey: timeoutKey, + millis: millis + }; + + if (runAtMillis in scheduledFunctions) { + scheduledFunctions[runAtMillis].push(funcToSchedule); + } else { + scheduledFunctions[runAtMillis] = [funcToSchedule]; + scheduledLookup.push(runAtMillis); + scheduledLookup.sort(function (a, b) { + return a - b; + }); + } + + return timeoutKey; + }; + + self.removeFunctionWithId = function(timeoutKey) { + for (var runAtMillis in scheduledFunctions) { + var funcs = scheduledFunctions[runAtMillis]; + var i = indexOfFirstToPass(funcs, function (func) { + return func.timeoutKey === timeoutKey; + }); + + if (i > -1) { + if (funcs.length === 1) { + delete scheduledFunctions[runAtMillis]; + deleteFromLookup(runAtMillis); + } else { + funcs.splice(i, 1); + } + + // intervals get rescheduled when executed, so there's never more + // than a single scheduled function with a given timeoutKey + break; + } + } + }; + + return self; + + function indexOfFirstToPass(array, testFn) { + var index = -1; + + for (var i = 0; i < array.length; ++i) { + if (testFn(array[i])) { + index = i; + break; + } + } + + return index; + } + + function deleteFromLookup(key) { + var value = Number(key); + var i = indexOfFirstToPass(scheduledLookup, function (millis) { + return millis === value; + }); + + if (i > -1) { + scheduledLookup.splice(i, 1); + } + } + + function reschedule(scheduledFn) { + self.scheduleFunction(scheduledFn.funcToCall, + scheduledFn.millis, + scheduledFn.params, + true, + scheduledFn.timeoutKey, + scheduledFn.runAtMillis + scheduledFn.millis); + } + + function forEachFunction(funcsToRun, callback) { + for (var i = 0; i < funcsToRun.length; ++i) { + callback(funcsToRun[i]); + } + } + + function runScheduledFunctions(endTime) { + if (scheduledLookup.length === 0 || scheduledLookup[0] > endTime) { + return; + } + + do { + currentTime = scheduledLookup.shift(); + + var funcsToRun = scheduledFunctions[currentTime]; + delete scheduledFunctions[currentTime]; + + forEachFunction(funcsToRun, function(funcToRun) { + if (funcToRun.recurring) { + reschedule(funcToRun); + } + }); + + forEachFunction(funcsToRun, function(funcToRun) { + funcToRun.funcToCall.apply(null, funcToRun.params || []); + }); + } while (scheduledLookup.length > 0 && + // checking first if we're out of time prevents setTimeout(0) + // scheduled in a funcToRun from forcing an extra iteration + currentTime !== endTime && + scheduledLookup[0] <= endTime); + } + } + + return DelayedFunctionScheduler; +}(); + + +j$.MockDate = function() { + function MockDate(global) { + var self = this; + var currentTime = 0; + + if (!global || !global.Date) { + self.install = function() {}; + self.tick = function() {}; + self.uninstall = function() {}; + return self; + } + + var GlobalDate = global.Date; + + self.install = function(mockDate) { + if (mockDate instanceof GlobalDate) { + currentTime = mockDate.getTime(); + } else { + currentTime = new GlobalDate().getTime(); + } + + global.Date = FakeDate; + }; + + self.tick = function(millis) { + millis = millis || 0; + currentTime = currentTime + millis; + }; + + self.uninstall = function() { + currentTime = 0; + global.Date = GlobalDate; + }; + + createDateProperties(); + + return self; + + function FakeDate() { + switch(arguments.length) { + case 0: + return new GlobalDate(currentTime); + case 1: + return new GlobalDate(arguments[0]); + case 2: + return new GlobalDate(arguments[0], arguments[1]); + case 3: + return new GlobalDate(arguments[0], arguments[1], arguments[2]); + case 4: + return new GlobalDate(arguments[0], arguments[1], arguments[2], arguments[3]); + case 5: + return new GlobalDate(arguments[0], arguments[1], arguments[2], arguments[3], + arguments[4]); + case 6: + return new GlobalDate(arguments[0], arguments[1], arguments[2], arguments[3], + arguments[4], arguments[5]); + default: + return new GlobalDate(arguments[0], arguments[1], arguments[2], arguments[3], + arguments[4], arguments[5], arguments[6]); + } + } + + function createDateProperties() { + FakeDate.prototype = GlobalDate.prototype; + + FakeDate.now = function() { + if (GlobalDate.now) { + return currentTime; + } else { + throw new Error('Browser does not support Date.now()'); + } + }; + + FakeDate.toSource = GlobalDate.toSource; + FakeDate.toString = GlobalDate.toString; + FakeDate.parse = GlobalDate.parse; + FakeDate.UTC = GlobalDate.UTC; + } + } + + return MockDate; +}(); + +var clock = new j$.Clock(global, function () { return new j$.DelayedFunctionScheduler(); }, new j$.MockDate(global)); + +module.exports.clock = function() { + return clock; +} + + diff --git a/test/test-component-index.js b/test/test-component-index.js index 9b5c07af54..0ab5f693dd 100644 --- a/test/test-component-index.js +++ b/test/test-component-index.js @@ -5,14 +5,17 @@ * provide */ var components = require('../src/component-index.js').components; -var stub = require('./components/stub-component.js'); +var stubComponent = require('./components/stub-component.js'); -components['structures.LeftPanel'] = stub; -components['structures.RightPanel'] = stub; -components['structures.RoomDirectory'] = stub; -components['views.globals.MatrixToolbar'] = stub; -components['views.globals.GuestWarningBar'] = stub; -components['views.globals.NewVersionBar'] = stub; -components['views.elements.Spinner'] = stub; +components['structures.LeftPanel'] = stubComponent(); +components['structures.RightPanel'] = stubComponent(); +components['structures.RoomDirectory'] = stubComponent(); +components['views.globals.MatrixToolbar'] = stubComponent(); +components['views.globals.GuestWarningBar'] = stubComponent(); +components['views.globals.NewVersionBar'] = stubComponent(); +components['views.elements.Spinner'] = stubComponent({displayName: 'Spinner'}); +components['views.messages.DateSeparator'] = stubComponent({displayName: 'DateSeparator'}); +components['views.messages.MessageTimestamp'] = stubComponent({displayName: 'MessageTimestamp'}); +components['views.messages.SenderProfile'] = stubComponent({displayName: 'SenderProfile'}); module.exports.components = components; diff --git a/test/test-utils.js b/test/test-utils.js index 7c0f38693a..956ced0554 100644 --- a/test/test-utils.js +++ b/test/test-utils.js @@ -2,8 +2,10 @@ var peg = require('../src/MatrixClientPeg.js'); var jssdk = require('matrix-js-sdk'); +var MatrixEvent = jssdk.MatrixEvent; var sinon = require('sinon'); + /** * Stub out the MatrixClient, and configure the MatrixClientPeg object to * return it when get() is called. @@ -16,6 +18,117 @@ module.exports.stubClient = function() { } +/** + * Create an Event. + * @param {Object} opts Values for the event. + * @param {string} opts.type The event.type + * @param {string} opts.room The event.room_id + * @param {string} opts.user The event.user_id + * @param {string} opts.skey Optional. The state key (auto inserts empty string) + * @param {Number} opts.ts Optional. Timestamp for the event + * @param {Object} opts.content The event.content + * @param {boolean} opts.event True to make a MatrixEvent. + * @return {Object} a JSON object representing this event. + */ +module.exports.mkEvent = function(opts) { + if (!opts.type || !opts.content) { + throw new Error("Missing .type or .content =>" + JSON.stringify(opts)); + } + var event = { + type: opts.type, + room_id: opts.room, + sender: opts.user, + content: opts.content, + event_id: "$" + Math.random() + "-" + Math.random(), + origin_server_ts: opts.ts, + }; + if (opts.skey) { + event.state_key = opts.skey; + } + else if (["m.room.name", "m.room.topic", "m.room.create", "m.room.join_rules", + "m.room.power_levels", "m.room.topic", + "com.example.state"].indexOf(opts.type) !== -1) { + event.state_key = ""; + } + return opts.event ? new MatrixEvent(event) : event; +}; + +/** + * Create an m.presence event. + * @param {Object} opts Values for the presence. + * @return {Object|MatrixEvent} The event + */ +module.exports.mkPresence = function(opts) { + if (!opts.user) { + throw new Error("Missing user"); + } + var event = { + event_id: "$" + Math.random() + "-" + Math.random(), + type: "m.presence", + sender: opts.user, + content: { + avatar_url: opts.url, + displayname: opts.name, + last_active_ago: opts.ago, + presence: opts.presence || "offline" + } + }; + return opts.event ? new MatrixEvent(event) : event; +}; + +/** + * Create an m.room.member event. + * @param {Object} opts Values for the membership. + * @param {string} opts.room The room ID for the event. + * @param {string} opts.mship The content.membership for the event. + * @param {string} opts.user The user ID for the event. + * @param {string} opts.skey The other user ID for the event if applicable + * e.g. for invites/bans. + * @param {string} opts.name The content.displayname for the event. + * @param {string} opts.url The content.avatar_url for the event. + * @param {boolean} opts.event True to make a MatrixEvent. + * @return {Object|MatrixEvent} The event + */ +module.exports.mkMembership = function(opts) { + opts.type = "m.room.member"; + if (!opts.skey) { + opts.skey = opts.user; + } + if (!opts.mship) { + throw new Error("Missing .mship => " + JSON.stringify(opts)); + } + opts.content = { + membership: opts.mship + }; + if (opts.name) { opts.content.displayname = opts.name; } + if (opts.url) { opts.content.avatar_url = opts.url; } + return module.exports.mkEvent(opts); +}; + +/** + * Create an m.room.message event. + * @param {Object} opts Values for the message + * @param {string} opts.room The room ID for the event. + * @param {string} opts.user The user ID for the event. + * @param {string} opts.msg Optional. The content.body for the event. + * @param {boolean} opts.event True to make a MatrixEvent. + * @return {Object|MatrixEvent} The event + */ +module.exports.mkMessage = function(opts) { + opts.type = "m.room.message"; + if (!opts.msg) { + opts.msg = "Random->" + Math.random(); + } + if (!opts.room || !opts.user) { + throw new Error("Missing .room or .user from", opts); + } + opts.content = { + msgtype: "m.text", + body: opts.msg + }; + return module.exports.mkEvent(opts); +}; + /** * make the test fail, with the given exception *