diff --git a/.gitignore b/.gitignore index 2bc43f86..df91879d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,9 @@ -node_modules -vector/bundle.* -lib -.DS_Store -key.pem -cert.pem -vector/components.css -packages/ +/cert.pem +/.DS_Store +/karma-reports +/key.pem +/lib +/node_modules +/packages/ +/vector/bundle.* +/vector/components.css diff --git a/jenkins.sh b/jenkins.sh index 634703eb..4e4b7fbb 100755 --- a/jenkins.sh +++ b/jenkins.sh @@ -13,6 +13,9 @@ npm install # we may be using a dev branch of react-sdk, in which case we need to build it (cd node_modules/matrix-react-sdk && npm run build) +# run the mocha tests +npm run test + # build our artifacts; dumps them in ./vector npm run build diff --git a/karma.conf.js b/karma.conf.js new file mode 100644 index 00000000..d2bc9255 --- /dev/null +++ b/karma.conf.js @@ -0,0 +1,134 @@ +// karma.conf.js - the config file for karma, which runs our tests. + +var path = require('path'); +var fs = require('fs'); +var webpack = require('webpack'); + +/* + * We use webpack to build our tests. It's a pain to have to wait for webpack + * 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. + */ + +// the name of the test file. By default, a special file which runs all tests. +var testFile = process.env.KARMA_TEST_FILE || 'test/all-tests.js'; + +process.env.PHANTOMJS_BIN = 'node_modules/.bin/phantomjs'; +process.env.Q_DEBUG = 1; + +module.exports = function (config) { + config.set({ + // frameworks to use + // available frameworks: https://npmjs.org/browse/keyword/karma-adapter + frameworks: ['mocha'], + + // list of files / patterns to load in the browser + files: [ + testFile, + {pattern: 'vector/img/*', watched: false, included: false, served: true, nocache: false}, + ], + + // redirect img links to the karma server + proxies: { + "/img/": "/base/vector/img/", + }, + + // preprocess matching files before serving them to the browser + // available preprocessors: + // https://npmjs.org/browse/keyword/karma-preprocessor + preprocessors: { + 'test/**/*.js': ['webpack', 'sourcemap'] + }, + + // test results reporter to use + // possible values: 'dots', 'progress' + // available reporters: https://npmjs.org/browse/keyword/karma-reporter + reporters: ['progress', 'junit'], + + // web server port + port: 9876, + + // enable / disable colors in the output (reporters and logs) + colors: true, + + // level of logging + // possible values: config.LOG_DISABLE || config.LOG_ERROR || + // config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG + logLevel: config.LOG_INFO, + + // enable / disable watching file and executing tests whenever any file + // changes + autoWatch: true, + + // start these browsers + // available browser launchers: + // https://npmjs.org/browse/keyword/karma-launcher + browsers: [ + 'Chrome', + //'PhantomJS', + ], + + // Continuous Integration mode + // if true, Karma captures browsers, runs the tests and exits + // singleRun: false, + + // Concurrency level + // how many browser should be started simultaneous + concurrency: Infinity, + + junitReporter: { + outputDir: 'karma-reports', + }, + + webpack: { + module: { + loaders: [ + { test: /\.json$/, loader: "json" }, + { + test: /\.js$/, loader: "babel", + include: [path.resolve('./src'), + path.resolve('./test'), + ], + query: { + // we're using babel 5, for consistency with + // the release build, which doesn't use the + // presets. + // presets: ['react', 'es2015'], + }, + }, + ], + noParse: [ + // don't parse the languages within highlight.js. They + // cause stack overflows + // (https://github.com/webpack/webpack/issues/1721), and + // 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: { + alias: { + // alias any requires to the react module to the one in our path, otherwise + // we tend to get the react source included twice when using npm link. + react: path.resolve('./node_modules/react'), + + 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 223672b8..51bacdce 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,9 @@ "start": "parallelshell \"npm run start:js\" \"npm run start:skins:css\" \"http-server -c 1 vector\"", "start:prod": "parallelshell \"npm run start:js:prod\" \"npm run start:skins:css\" \"http-server -c 1 vector\"", "clean": "rimraf lib vector/bundle.css vector/bundle.js vector/bundle.js.map vector/webpack.css*", - "prepublish": "npm run build:css && npm run build:compile" + "prepublish": "npm run build:css && npm run build:compile", + "test": "karma start --single-run=true --browsers PhantomJS", + "test:multi": "karma start" }, "dependencies": { "babel-polyfill": "^6.5.0", @@ -41,7 +43,7 @@ "matrix-react-sdk": "matrix-org/matrix-react-sdk#develop", "modernizr": "^3.1.0", "q": "^1.4.1", - "react": "^0.14.2", + "react": "^0.14.8", "react-dnd": "^2.0.2", "react-dnd-html5-backend": "^2.0.0", "react-dom": "^0.14.2", @@ -54,11 +56,23 @@ "babel-loader": "^5.3.2", "catw": "^1.0.1", "css-raw-loader": "^0.1.1", + "expect": "^1.16.0", "http-server": "^0.8.4", "json-loader": "^0.5.3", + "karma": "^0.13.22", + "karma-chrome-launcher": "^0.2.3", + "karma-cli": "^0.1.2", + "karma-junit-reporter": "^0.4.1", + "karma-mocha": "^0.2.2", + "karma-phantomjs-launcher": "^1.0.0", + "karma-sourcemap-loader": "^0.3.7", + "karma-webpack": "^1.7.0", + "mocha": "^2.4.5", "parallelshell": "^1.2.0", + "phantomjs-prebuilt": "^2.1.7", + "react-addons-test-utils": "^0.14.8", "rimraf": "^2.4.3", "source-map-loader": "^0.1.5", - "webpack": "^1.12.13" + "webpack": "^1.12.14" } } diff --git a/test/all-tests.js b/test/all-tests.js new file mode 100644 index 00000000..ec300820 --- /dev/null +++ b/test/all-tests.js @@ -0,0 +1,7 @@ +// all-tests.js +// +// Our master test file: uses the webpack require API to find our test files +// and run them + +var context = require.context('./app-tests', true, /\.jsx?$/); +context.keys().forEach(context); diff --git a/test/app-tests/joining.js b/test/app-tests/joining.js new file mode 100644 index 00000000..d25c3590 --- /dev/null +++ b/test/app-tests/joining.js @@ -0,0 +1,165 @@ +/* +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. +*/ + +/* joining.js: tests for the various paths when joining a room */ + +require('skin-sdk'); + +var jssdk = require('matrix-js-sdk'); + +var sdk = require('matrix-react-sdk'); +var peg = require('matrix-react-sdk/lib/MatrixClientPeg'); +var dis = require('matrix-react-sdk/lib/dispatcher'); +var MatrixChat = sdk.getComponent('structures.MatrixChat'); +var RoomDirectory = sdk.getComponent('structures.RoomDirectory'); +var RoomPreviewBar = sdk.getComponent('rooms.RoomPreviewBar'); +var RoomView = sdk.getComponent('structures.RoomView'); + +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 test_utils = require('../test-utils'); +var MockHttpBackend = require('../mock-request'); + +var HS_URL='http://localhost'; +var IS_URL='http://localhost'; +var USER_ID='@me:localhost'; +var ACCESS_TOKEN='access_token'; + +describe('joining a room', function () { + describe('over federation', function () { + var parentDiv; + var httpBackend; + var matrixChat; + + beforeEach(function() { + test_utils.beforeEach(this); + httpBackend = new MockHttpBackend(); + jssdk.request(httpBackend.requestFn); + parentDiv = document.createElement('div'); + document.body.appendChild(parentDiv); + }); + + afterEach(function() { + if (parentDiv) { + ReactDOM.unmountComponentAtNode(parentDiv); + document.body.removeChild(parentDiv); + parentDiv = null; + } + httpBackend.verifyNoOutstandingRequests(); + }); + + it('should not get stuck at a spinner', function(done) { + var ROOM_ALIAS = '#alias:localhost'; + var ROOM_ID = '!id:localhost'; + + httpBackend.when('PUT', '/presence/'+encodeURIComponent(USER_ID)+'/status') + .respond(200, {}); + if (test_utils.browserSupportsWebRTC()) { + httpBackend.when('GET', '/voip/turnServer').respond(200, {}); + } + httpBackend.when('GET', '/pushrules').respond(200, {}); + httpBackend.when('POST', '/filter').respond(200, { filter_id: 'fid' }); + httpBackend.when('GET', '/sync').respond(200, {}); + httpBackend.when('GET', '/publicRooms').respond(200, {chunk: []}); + + // start with a logged-in client + peg.replaceUsingAccessToken(HS_URL, IS_URL, USER_ID, ACCESS_TOKEN); + + var mc = ; + matrixChat = ReactDOM.render(mc, parentDiv); + + // switch to the Directory + dis.dispatch({ + action: 'view_room_directory', + }); + + var roomView; + httpBackend.flush().then(() => { + var roomDir = ReactTestUtils.findRenderedComponentWithType( + matrixChat, RoomDirectory); + + // enter an alias in the input, and simulate enter + var input = ReactTestUtils.findRenderedDOMComponentWithTag( + roomDir, 'input'); + input.value = ROOM_ALIAS; + ReactTestUtils.Simulate.keyUp(input, {key: 'Enter'}); + + // that should create a roomview which will start a peek; wait + // for the peek. + httpBackend.when('GET', '/rooms/'+encodeURIComponent(ROOM_ALIAS)+"/initialSync") + .respond(401, {errcode: 'M_GUEST_ACCESS_FORBIDDEN'}); + return httpBackend.flush(); + }).then(() => { + httpBackend.verifyNoOutstandingExpectation(); + + // we should now have a roomview, with a preview bar + roomView = ReactTestUtils.findRenderedComponentWithType( + matrixChat, RoomView); + + var previewBar = ReactTestUtils.findRenderedComponentWithType( + roomView, RoomPreviewBar); + + var joinLink = ReactTestUtils.findRenderedDOMComponentWithTag( + previewBar, 'a'); + + ReactTestUtils.Simulate.click(joinLink); + + // that will fire off a request to check our displayname, followed by a + // join request + httpBackend.when('GET', '/profile/'+encodeURIComponent(USER_ID)) + .respond(200, {displayname: 'boris'}); + httpBackend.when('POST', '/join/'+encodeURIComponent(ROOM_ALIAS)) + .respond(200, {room_id: ROOM_ID}); + return httpBackend.flush(); + }).then(() => { + httpBackend.verifyNoOutstandingExpectation(); + + // the roomview should now be loading + expect(roomView.state.room).toBe(null); + expect(roomView.state.joining).toBe(true); + + // there should be a spinner + ReactTestUtils.findRenderedDOMComponentWithClass( + roomView, "mx_Spinner"); + + // now send the room down the /sync pipe + httpBackend.when('GET', '/sync'). + respond(200, { + rooms: { + join: { + [ROOM_ID]: { + state: {}, + timeline: { + events: [], + limited: true, + }, + }, + }, + }, + }); + return httpBackend.flush(); + }).then(() => { + // now the room should have loaded + expect(roomView.state.room).toExist(); + expect(roomView.state.joining).toBe(false); + }).done(done, done); + }); + }); +}); diff --git a/test/mock-request.js b/test/mock-request.js new file mode 100644 index 00000000..8510ad54 --- /dev/null +++ b/test/mock-request.js @@ -0,0 +1,228 @@ +"use strict"; +var q = require("q"); +var expect = require('expect'); + +/** + * Construct a mock HTTP backend, heavily inspired by Angular.js. + * @constructor + */ +function HttpBackend() { + this.requests = []; + this.expectedRequests = []; + var self = this; + // the request function dependency that the SDK needs. + this.requestFn = function(opts, callback) { + var realReq = new Request(opts.method, opts.uri, opts.body, opts.qs); + realReq.callback = callback; + console.log("HTTP backend received request: %s %s", opts.method, opts.uri); + self.requests.push(realReq); + + var abort = function() { + var idx = self.requests.indexOf(realReq); + if (idx >= 0) { + console.log("Aborting HTTP request: %s %s", opts.method, opts.uri); + self.requests.splice(idx, 1); + } + } + return { + abort: abort + }; + }; +} +HttpBackend.prototype = { + /** + * Respond to all of the requests (flush the queue). + * @param {string} path The path to flush (optional) default: all. + * @param {integer} numToFlush The number of things to flush (optional), default: all. + * @return {Promise} resolved when there is nothing left to flush. + */ + flush: function(path, numToFlush) { + var defer = q.defer(); + var self = this; + var flushed = 0; + var triedWaiting = false; + console.log( + "HTTP backend flushing... (path=%s numToFlush=%s)", path, numToFlush + ); + var tryFlush = function() { + // if there's more real requests and more expected requests, flush 'em. + console.log( + " trying to flush queue => reqs=%s expected=%s [%s]", + self.requests.length, self.expectedRequests.length, path + ); + if (self._takeFromQueue(path)) { + // try again on the next tick. + console.log(" flushed. Trying for more. [%s]", path); + flushed += 1; + if (numToFlush && flushed === numToFlush) { + console.log(" [%s] Flushed assigned amount: %s", path, numToFlush); + defer.resolve(); + } + else { + setTimeout(tryFlush, 0); + } + } + else if (flushed === 0 && !triedWaiting) { + // we may not have made the request yet, wait a generous amount of + // time before giving up. + setTimeout(tryFlush, 5); + triedWaiting = true; + } + else { + console.log(" no more flushes. [%s]", path); + defer.resolve(); + } + }; + + setTimeout(tryFlush, 0); + + return defer.promise; + }, + + /** + * Attempts to resolve requests/expected requests. + * @param {string} path The path to flush (optional) default: all. + * @return {boolean} true if something was resolved. + */ + _takeFromQueue: function(path) { + var req = null; + var i, j; + var matchingReq, expectedReq, testResponse = null; + for (i = 0; i < this.requests.length; i++) { + req = this.requests[i]; + for (j = 0; j < this.expectedRequests.length; j++) { + expectedReq = this.expectedRequests[j]; + if (path && path !== expectedReq.path) { continue; } + if (expectedReq.method === req.method && + req.path.indexOf(expectedReq.path) !== -1) { + if (!expectedReq.data || (JSON.stringify(expectedReq.data) === + JSON.stringify(req.data))) { + matchingReq = expectedReq; + this.expectedRequests.splice(j, 1); + break; + } + } + } + + if (matchingReq) { + // remove from request queue + this.requests.splice(i, 1); + i--; + + for (j = 0; j < matchingReq.checks.length; j++) { + matchingReq.checks[j](req); + } + testResponse = matchingReq.response; + console.log(" responding to %s", matchingReq.path); + var body = testResponse.body; + if (Object.prototype.toString.call(body) == "[object Function]") { + body = body(req.path, req.data); + } + req.callback( + testResponse.err, testResponse.response, body + ); + matchingReq = null; + } + } + if (testResponse) { // flushed something + return true; + } + return false; + }, + + /** + * Makes sure that the SDK hasn't sent any more requests to the backend. + */ + verifyNoOutstandingRequests: function() { + var firstOutstandingReq = this.requests[0] || {}; + expect(this.requests.length).toEqual(0, + "Expected no more HTTP requests but received request to " + + firstOutstandingReq.path + ); + }, + + /** + * Makes sure that the test doesn't have any unresolved requests. + */ + verifyNoOutstandingExpectation: function() { + var firstOutstandingExpectation = this.expectedRequests[0] || {}; + expect(this.expectedRequests.length).toEqual( + 0, + "Expected to see HTTP request for " + + firstOutstandingExpectation.method + + " " + firstOutstandingExpectation.path + ); + }, + + /** + * Create an expected request. + * @param {string} method The HTTP method + * @param {string} path The path (which can be partial) + * @param {Object} data The expected data. + * @return {Request} An expected request. + */ + when: function(method, path, data) { + var pendingReq = new Request(method, path, data); + this.expectedRequests.push(pendingReq); + return pendingReq; + } +}; + +function Request(method, path, data, queryParams) { + this.method = method; + this.path = path; + this.data = data; + this.queryParams = queryParams; + this.callback = null; + this.response = null; + this.checks = []; +} +Request.prototype = { + /** + * Execute a check when this request has been satisfied. + * @param {Function} fn The function to execute. + * @return {Request} for chaining calls. + */ + check: function(fn) { + this.checks.push(fn); + return this; + }, + + /** + * Respond with the given data when this request is satisfied. + * @param {Number} code The HTTP status code. + * @param {Object|Function} data The HTTP JSON body. If this is a function, + * it will be invoked when the JSON body is required (which should be returned). + */ + respond: function(code, data) { + this.response = { + response: { + statusCode: code, + headers: {} + }, + body: data, + err: null + }; + }, + + /** + * Fail with an Error when this request is satisfied. + * @param {Number} code The HTTP status code. + * @param {Error} err The error to throw (e.g. Network Error) + */ + fail: function(code, err) { + this.response = { + response: { + statusCode: code, + headers: {} + }, + body: null, + err: err + }; + }, +}; + +/** + * The HttpBackend class. + */ +module.exports = HttpBackend; diff --git a/test/skin-sdk.js b/test/skin-sdk.js new file mode 100644 index 00000000..a5a7233c --- /dev/null +++ b/test/skin-sdk.js @@ -0,0 +1,8 @@ +/* + * skin-sdk.js + * + * Skins the react-sdk with the vector components + */ + +var sdk = require('matrix-react-sdk'); +sdk.loadSkin(require('component-index')); diff --git a/test/test-utils.js b/test/test-utils.js new file mode 100644 index 00000000..ad58b805 --- /dev/null +++ b/test/test-utils.js @@ -0,0 +1,24 @@ +"use strict"; + +var q = require('q'); + +/** + * 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("=")); +}; + +/** + * returns true if the current environment supports webrtc + */ +module.exports.browserSupportsWebRTC = function() { + var n = global.window.navigator; + return n.getUserMedia || n.webkitGetUserMedia || + n.mozGetUserMedia; +};