diff --git a/.gitignore b/.gitignore index 88230e825..72ab8b789 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules npm-debug.log +static/context.js static/karma.js .idea/* *.iml diff --git a/client/karma.js b/client/karma.js index 01d5f1e29..d54fe34e2 100644 --- a/client/karma.js +++ b/client/karma.js @@ -1,9 +1,8 @@ -var stringify = require('./stringify') +var stringify = require('../common/stringify') var constant = require('./constants') -var util = require('./util') +var util = require('../common/util') var Karma = function (socket, iframe, opener, navigator, location) { - var hasError = false var startEmitted = false var reloadingContext = false var self = this @@ -22,73 +21,47 @@ var Karma = function (socket, iframe, opener, navigator, location) { // registry anymore. this.socket = socket + // Set up postMessage bindings for current window + // DEV: These are to allow windows in separate processes execute local tasks + // Electron is one of these environments + if (window.addEventListener) { + window.addEventListener('message', function handleMessage (evt) { + // Resolve the origin of our message + var origin = evt.origin || evt.originalEvent.origin + + // If the message isn't from our host, then reject it + if (origin !== window.location.origin) { + return + } + + // Take action based on the message type + var method = evt.data.method + if (!self[method]) { + self.error('Received `postMessage` for "' + method + '" but the method doesn\'t exist') + return + } + self[method].apply(self, evt.data.arguments) + }, false) + } + var childWindow = null var navigateContextTo = function (url) { if (self.config.useIframe === false) { - if (childWindow === null || childWindow.closed === true) { - // If this is the first time we are opening the window, or the window is closed - childWindow = opener('about:blank') + // If there is a window already open, then close it + // DEV: In some environments (e.g. Electron), we don't have setter access for location + if (childWindow !== null && childWindow.closed !== true) { + childWindow.close() } - childWindow.location = url + childWindow = opener(url) } else { iframe.src = url } } - this.setupContext = function (contextWindow) { - if (self.config.clearContext && hasError) { - return - } - - var getConsole = function (currentWindow) { - return currentWindow.console || { - log: function () {}, - info: function () {}, - warn: function () {}, - error: function () {}, - debug: function () {} - } - } - - contextWindow.__karma__ = this - - // This causes memory leak in Chrome (17.0.963.66) - contextWindow.onerror = function () { - return contextWindow.__karma__.error.apply(contextWindow.__karma__, arguments) - } - - contextWindow.onbeforeunload = function (e, b) { - if (!reloadingContext) { - // TODO(vojta): show what test (with explanation about jasmine.UPDATE_INTERVAL) - contextWindow.__karma__.error('Some of your tests did a full page reload!') - } - } - - if (self.config.captureConsole) { - // patch the console - var localConsole = contextWindow.console = getConsole(contextWindow) - var logMethods = ['log', 'info', 'warn', 'error', 'debug'] - var patchConsoleMethod = function (method) { - var orig = localConsole[method] - if (!orig) { - return - } - localConsole[method] = function () { - self.log(method, arguments) - return Function.prototype.apply.call(orig, localConsole, arguments) - } - } - for (var i = 0; i < logMethods.length; i++) { - patchConsoleMethod(logMethods[i]) - } - } - - contextWindow.dump = function () { - self.log('dump', arguments) - } - - contextWindow.alert = function (msg) { - self.log('alert', [msg]) + this.onbeforeunload = function () { + if (!reloadingContext) { + // TODO(vojta): show what test (with explanation about jasmine.UPDATE_INTERVAL) + self.error('Some of your tests did a full page reload!') } } @@ -113,7 +86,6 @@ var Karma = function (socket, iframe, opener, navigator, location) { // error during js file loading (most likely syntax error) // we are not going to execute at all this.error = function (msg, url, line) { - hasError = true var message = msg if (url) { @@ -174,28 +146,8 @@ var Karma = function (socket, iframe, opener, navigator, location) { } } - var UNIMPLEMENTED_START = function () { - this.error('You need to include some adapter that implements __karma__.start method!') - } - - // all files loaded, let's start the execution - this.loaded = function () { - // has error -> cancel - if (!hasError) { - this.start(this.config) - } - - // remove reference to child iframe - this.start = UNIMPLEMENTED_START - } - - // supposed to be overriden by the context - // TODO(vojta): support multiple callbacks (queue) - this.start = UNIMPLEMENTED_START - socket.on('execute', function (cfg) { - // reset hasError and reload the iframe - hasError = false + // reset startEmitted and reload the iframe startEmitted = false self.config = cfg // if not clearing context, reloadingContext always true to prevent beforeUnload error diff --git a/client/main.js b/client/main.js index 93406c4be..ed9200013 100644 --- a/client/main.js +++ b/client/main.js @@ -4,7 +4,7 @@ require('core-js/es5') var Karma = require('./karma') var StatusUpdater = require('./updater') -var util = require('./util') +var util = require('../common/util') var KARMA_URL_ROOT = require('./constants').KARMA_URL_ROOT diff --git a/client/stringify.js b/common/stringify.js similarity index 100% rename from client/stringify.js rename to common/stringify.js diff --git a/client/util.js b/common/util.js similarity index 100% rename from client/util.js rename to common/util.js diff --git a/context/karma.js b/context/karma.js new file mode 100644 index 000000000..ba646ec09 --- /dev/null +++ b/context/karma.js @@ -0,0 +1,138 @@ +// Load our dependencies +var stringify = require('../common/stringify') + +// Define our context Karma constructor +var ContextKarma = function (callParentKarmaMethod) { + // Define local variables + var hasError = false + var self = this + + // Define our loggers + // DEV: These are intentionally repeated in client and context + this.log = function (type, args) { + var values = [] + + for (var i = 0; i < args.length; i++) { + values.push(this.stringify(args[i], 3)) + } + + this.info({log: values.join(', '), type: type}) + } + + this.stringify = stringify + + // Define our proxy error handler + // DEV: We require one in our context to track `hasError` + this.error = function () { + hasError = true + callParentKarmaMethod('error', [].slice.call(arguments)) + return false + } + + // Define our start handler + var UNIMPLEMENTED_START = function () { + this.error('You need to include some adapter that implements __karma__.start method!') + } + // all files loaded, let's start the execution + this.loaded = function () { + // has error -> cancel + if (!hasError) { + this.start(this.config) + } + + // remove reference to child iframe + this.start = UNIMPLEMENTED_START + } + // supposed to be overriden by the context + // TODO(vojta): support multiple callbacks (queue) + this.start = UNIMPLEMENTED_START + + // Define proxy methods + // DEV: This is a closured `for` loop (same as a `forEach`) for IE support + var proxyMethods = ['complete', 'info', 'result'] + for (var i = 0; i < proxyMethods.length; i++) { + (function bindProxyMethod (methodName) { + self[methodName] = function boundProxyMethod () { + callParentKarmaMethod(methodName, [].slice.call(arguments)) + } + }(proxyMethods[i])) + } + + // Define bindings for context window + this.setupContext = function (contextWindow) { + // If we clear the context after every run and we already had an error + // then stop now. Otherwise, carry on. + if (self.config.clearContext && hasError) { + return + } + + // Perform window level bindings + // DEV: We return `self.error` since we want to `return false` to ignore errors + contextWindow.onerror = function () { + return self.error.apply(self, arguments) + } + // DEV: We must defined a function since we don't want to pass the event object + contextWindow.onbeforeunload = function (e, b) { + callParentKarmaMethod('onbeforeunload', []) + } + + contextWindow.dump = function () { + self.log('dump', arguments) + } + + contextWindow.alert = function (msg) { + self.log('alert', [msg]) + } + + // If we want to overload our console, then do it + var getConsole = function (currentWindow) { + return currentWindow.console || { + log: function () {}, + info: function () {}, + warn: function () {}, + error: function () {}, + debug: function () {} + } + } + if (self.config.captureConsole) { + // patch the console + var localConsole = contextWindow.console = getConsole(contextWindow) + var logMethods = ['log', 'info', 'warn', 'error', 'debug'] + var patchConsoleMethod = function (method) { + var orig = localConsole[method] + if (!orig) { + return + } + localConsole[method] = function () { + self.log(method, arguments) + return Function.prototype.apply.call(orig, localConsole, arguments) + } + } + for (var i = 0; i < logMethods.length; i++) { + patchConsoleMethod(logMethods[i]) + } + } + } +} + +// Define call/proxy methods +ContextKarma.getDirectCallParentKarmaMethod = function (parentWindow) { + return function directCallParentKarmaMethod (method, args) { + // If the method doesn't exist, then error out + if (!parentWindow.karma[method]) { + parentWindow.karma.error('Expected Karma method "' + method + '" to exist but it doesn\'t') + return + } + + // Otherwise, run our method + parentWindow.karma[method].apply(parentWindow.karma, args) + } +} +ContextKarma.getPostMessageCallParentKarmaMethod = function (parentWindow) { + return function postMessageCallParentKarmaMethod (method, args) { + parentWindow.postMessage({method: method, arguments: args}, window.location.origin) + } +} + +// Export our module +module.exports = ContextKarma diff --git a/context/main.js b/context/main.js new file mode 100644 index 000000000..89b450c31 --- /dev/null +++ b/context/main.js @@ -0,0 +1,21 @@ +// Load in our dependencies +var ContextKarma = require('./karma') + +// Resolve our parent window +var parentWindow = window.opener || window.parent + +// Define a remote call method for Karma +var callParentKarmaMethod = ContextKarma.getDirectCallParentKarmaMethod(parentWindow) + +// If we don't have access to the window, then use `postMessage` +// DEV: In Electron, we don't have access to the parent window due to it being in a separate process +// DEV: We avoid using this in Internet Explorer as they only support strings +// http://caniuse.com/#search=postmessage +var haveParentAccess = false +try { haveParentAccess = !!parentWindow.window } catch (err) { /* Ignore errors (likely permisison errors) */ } +if (!haveParentAccess) { + callParentKarmaMethod = ContextKarma.getPostMessageCallParentKarmaMethod(parentWindow) +} + +// Define a window-scoped Karma +window.__karma__ = new ContextKarma(callParentKarmaMethod) diff --git a/gruntfile.js b/gruntfile.js index 6d644cf4d..42a4423dc 100644 --- a/gruntfile.js +++ b/gruntfile.js @@ -5,13 +5,16 @@ module.exports = function (grunt) { files: { server: ['lib/**/*.js'], client: ['client/**/*.js'], + common: ['common/**/*.js'], + context: ['context/**/*.js'], grunt: ['grunt.js', 'tasks/*.js'], scripts: ['scripts/init-dev-env.js'] }, browserify: { client: { files: { - 'static/karma.js': ['client/main.js'] + 'static/karma.js': ['client/main.js'], + 'static/context.js': ['context/main.js'] } } }, @@ -76,7 +79,8 @@ module.exports = function (grunt) { '<%= files.grunt %>', '<%= files.scripts %>', '<%= files.client %>', - 'static/context.js', + '<%= files.common %>', + '<%= files.context %>', 'static/debug.js', 'test/**/*.js', 'gruntfile.js' diff --git a/static/context.js b/static/context.js deleted file mode 100644 index 49f48f4db..000000000 --- a/static/context.js +++ /dev/null @@ -1,13 +0,0 @@ -// Define a placeholder for Karma to be defined via the parent window -// DEV: This is a placeholder change for upcoming edits in https://github.com/karma-runner/karma/pull/1984 -window.__karma__ = { - setupContext: function (contextWindow) { - // sets window.__karma__ and overrides console and error handling - // Use window.opener if this was opened by someone else - in a new window - if (contextWindow.opener) { - contextWindow.opener.karma.setupContext(contextWindow) - } else { - contextWindow.parent.karma.setupContext(contextWindow) - } - } -} diff --git a/test/client/karma.spec.js b/test/client/karma.spec.js index 462f75d0c..79c390150 100644 --- a/test/client/karma.spec.js +++ b/test/client/karma.spec.js @@ -4,11 +4,12 @@ global.JSON = require('json3') var sinon = require('sinon') var assert = require('assert') -var Karma = require('../../client/karma') +var ClientKarma = require('../../client/karma') +var ContextKarma = require('../../context/karma') var MockSocket = require('./mocks').Socket describe('Karma', function () { - var socket, k, windowNavigator, windowLocation, windowStub, startSpy, iframe + var socket, k, ck, windowNavigator, windowLocation, windowStub, startSpy, iframe, clientWindow var setTransportTo = function (transportName) { socket._setTransportNameTo(transportName) @@ -22,33 +23,38 @@ describe('Karma', function () { windowLocation = {search: ''} windowStub = sinon.stub().returns({}) - k = new Karma(socket, iframe, windowStub, windowNavigator, windowLocation) - startSpy = sinon.spy(k, 'start') + k = new ClientKarma(socket, iframe, windowStub, windowNavigator, windowLocation) + clientWindow = { + karma: k + } + ck = new ContextKarma(ContextKarma.getDirectCallParentKarmaMethod(clientWindow)) + ck.config = {} + startSpy = sinon.spy(ck, 'start') }) it('should start execution when all files loaded and pass config', function () { - var config = { + var config = ck.config = { useIframe: true } socket.emit('execute', config) assert(!startSpy.called) - k.loaded() + ck.loaded() assert(startSpy.calledWith(config)) }) it('should open a new window when useIFrame is false', function () { - var config = { + var config = ck.config = { useIframe: false } socket.emit('execute', config) - assert(!k.start.called) + assert(!ck.start.called) - k.loaded() + ck.loaded() assert(startSpy.calledWith(config)) - assert(windowStub.calledWith('about:blank')) + assert(windowStub.calledWith('context.html')) }) it('should stop execution', function () { @@ -58,27 +64,27 @@ describe('Karma', function () { }) it('should not start execution if any error during loading files', function () { - k.error('syntax error', '/some/file.js', 11) - k.loaded() - sinon.spy(k, 'start') + ck.error('syntax error', '/some/file.js', 11) + ck.loaded() + sinon.spy(ck, 'start') assert(!startSpy.called) }) it('should remove reference to start even after syntax error', function () { var ADAPTER_START_FN = function () {} - k.start = ADAPTER_START_FN - k.error('syntax error', '/some/file.js', 11) - k.loaded() - assert.notEqual(k.start, ADAPTER_START_FN) + ck.start = ADAPTER_START_FN + ck.error('syntax error', '/some/file.js', 11) + ck.loaded() + assert.notEqual(ck.start, ADAPTER_START_FN) - k.start = ADAPTER_START_FN - k.loaded() + ck.start = ADAPTER_START_FN + ck.loaded() assert.notEqual(k.start, ADAPTER_START_FN) }) it('should not set up context if there was an error', function () { - var config = { + var config = ck.config = { clearContext: true } @@ -86,16 +92,15 @@ describe('Karma', function () { var mockWindow = {} - k.error('page reload') - k.setupContext(mockWindow) + ck.error('page reload') + ck.setupContext(mockWindow) - assert(mockWindow.__karma__ == null) - assert(mockWindow.onbeforeunloadK == null) + assert(mockWindow.onbeforeunload == null) assert(mockWindow.onerror == null) }) it('should setup context if there was error but clearContext config is false', function () { - var config = { + var config = ck.config = { clearContext: false } @@ -103,14 +108,32 @@ describe('Karma', function () { var mockWindow = {} - k.error('page reload') - k.setupContext(mockWindow) + ck.error('page reload') + ck.setupContext(mockWindow) - assert(mockWindow.__karma__ != null) assert(mockWindow.onbeforeunload != null) assert(mockWindow.onerror != null) }) + it('should error out if a script attempted to reload the browser after setup', function () { + // Perform setup + var config = ck.config = { + clearContext: true + } + socket.emit('execute', config) + var mockWindow = {} + ck.setupContext(mockWindow) + + // Spy on our error handler + sinon.spy(k, 'error') + + // Emulate an unload event + mockWindow.onbeforeunload() + + // Assert our spy was called + assert(k.error.calledWith('Some of your tests did a full page reload!')) + }) + it('should report navigator name', function () { var spyInfo = sinon.spy(function (info) { assert(info.name === 'Fake browser name') @@ -127,7 +150,7 @@ describe('Karma', function () { it('should report browser id', function () { windowLocation.search = '?id=567' socket = new MockSocket() - k = new Karma(socket, {}, windowStub, windowNavigator, windowLocation) + k = new ClientKarma(socket, {}, windowStub, windowNavigator, windowLocation) var spyInfo = sinon.spy(function (info) { assert(info.id === '567') @@ -148,12 +171,12 @@ describe('Karma', function () { // emit 49 results for (var i = 1; i < 50; i++) { - k.result({id: i}) + ck.result({id: i}) } assert(!spyResult.called) - k.result('result', {id: 50}) + ck.result('result', {id: 50}) assert(spyResult.called) assert(spyResult.args[0][0].length === 50) }) @@ -166,10 +189,10 @@ describe('Karma', function () { // emit 40 results for (var i = 1; i <= 40; i++) { - k.result({id: i}) + ck.result({id: i}) } - k.complete() + ck.complete() assert(spyResult.called) assert(spyResult.args[0][0].length === 40) }) @@ -188,7 +211,7 @@ describe('Karma', function () { setTransportTo('websocket') // adapter didn't call info({total: x}) - k.result() + ck.result() assert.deepEqual(log, ['start', 'result']) }) @@ -208,8 +231,8 @@ describe('Karma', function () { setTransportTo('websocket') - k.info({total: 321}) - k.result() + ck.info({total: 321}) + ck.result() assert.deepEqual(log, ['start', 'result']) assert(spyStart.calledWith({total: 321})) }) @@ -217,7 +240,7 @@ describe('Karma', function () { describe('setupContext', function () { it('should capture alert', function () { - sinon.spy(k, 'log') + sinon.spy(ck, 'log') var mockWindow = { alert: function () { @@ -225,9 +248,9 @@ describe('Karma', function () { } } - k.setupContext(mockWindow) + ck.setupContext(mockWindow) mockWindow.alert('What?') - assert(k.log.calledWith('alert', ['What?'])) + assert(ck.log.calledWith('alert', ['What?'])) }) }) @@ -250,19 +273,22 @@ describe('Karma', function () { // emit 40 results for (var i = 0; i < 40; i++) { - k.result({id: i}) + ck.result({id: i}) } assert(!spyResult.called) - k.complete() + ck.complete() assert(spyResult.called) }) it('should navigate the client to return_url if specified', function (done) { windowLocation.search = '?id=567&return_url=http://return.com' socket = new MockSocket() - k = new Karma(socket, {}, windowStub, windowNavigator, windowLocation) + k = new ClientKarma(socket, {}, windowStub, windowNavigator, windowLocation) + clientWindow = {karma: k} + ck = new ContextKarma(ContextKarma.getDirectCallParentKarmaMethod(clientWindow)) + ck.config = {} sinon.spy(socket, 'disconnect') @@ -270,7 +296,7 @@ describe('Karma', function () { ack() }) - k.complete() + ck.complete() clock.tick(500) setTimeout(function () { @@ -281,8 +307,8 @@ describe('Karma', function () { }) it('should patch the console if captureConsole is true', function () { - sinon.spy(k, 'log') - k.config.captureConsole = true + sinon.spy(ck, 'log') + ck.config.captureConsole = true var mockWindow = { console: { @@ -290,15 +316,15 @@ describe('Karma', function () { } } - k.setupContext(mockWindow) + ck.setupContext(mockWindow) mockWindow.console.log('What?') - assert(k.log.calledWith('log')) - assert(k.log.args[0][1][0] === 'What?') + assert(ck.log.calledWith('log')) + assert(ck.log.args[0][1][0] === 'What?') }) it('should not patch the console if captureConsole is false', function () { - sinon.spy(k, 'log') - k.config.captureConsole = false + sinon.spy(ck, 'log') + ck.config.captureConsole = false var mockWindow = { console: { @@ -306,20 +332,20 @@ describe('Karma', function () { } } - k.setupContext(mockWindow) + ck.setupContext(mockWindow) mockWindow.console.log('hello') - assert(!k.log.called) + assert(!ck.log.called) }) it('should clear context window upon complete when clearContext config is true', function () { - var config = { + var config = ck.config = { clearContext: true } socket.emit('execute', config) var CURRENT_URL = iframe.src - k.complete() + ck.complete() // clock.tick() does not work in IE 7 setTimeout(function () { @@ -329,14 +355,14 @@ describe('Karma', function () { }) it('should not clear context window upon complete when clearContext config is false', function () { - var config = { + var config = ck.config = { clearContext: false } socket.emit('execute', config) var CURRENT_URL = iframe.src - k.complete() + ck.complete() clock.tick(1) diff --git a/test/client/stringify.spec.js b/test/client/stringify.spec.js index ecd44d4b4..a2e8ea11e 100644 --- a/test/client/stringify.spec.js +++ b/test/client/stringify.spec.js @@ -1,7 +1,7 @@ /* global __karma__ */ var assert = require('assert') -var stringify = require('../../client/stringify') +var stringify = require('../../common/stringify') describe('stringify', function () { it('should serialize string', function () { diff --git a/test/client/util.spec.js b/test/client/util.spec.js index 88bd4fa31..035ffc49b 100644 --- a/test/client/util.spec.js +++ b/test/client/util.spec.js @@ -1,6 +1,6 @@ var assert = require('assert') -var util = require('../../client/util') +var util = require('../../common/util') describe('util', function () { it('parseQueryParams', function () {