Skip to content

Commit

Permalink
feat(launcher): Add concurrency limit
Browse files Browse the repository at this point in the history
Especially services like Browserstack and SauceLabs have limitations on
how many browsers
can be launched at the same time. The new config option `concurrency`
allows to
specify an upper limit of how many browsers are allowed to run at the
same time.

Ref: karma-runner/karma-sauce-launcher#40

Closes karma-runner#1465
  • Loading branch information
dignifiedquire committed Oct 21, 2015
1 parent b138619 commit 99696db
Show file tree
Hide file tree
Showing 9 changed files with 124 additions and 36 deletions.
4 changes: 4 additions & 0 deletions config.tpl.coffee
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,7 @@ module.exports = (config) ->
# Continuous Integration mode
# if true, Karma captures browsers, runs the tests and exits
singleRun: false

# Concurrency level
# how many browser should be started simultanous
concurrency: Infinity
6 changes: 5 additions & 1 deletion config.tpl.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ module.exports = function(config) {

// Continuous Integration mode
// if true, Karma captures browsers, runs the tests and exits
singleRun: false
singleRun: false,

// Concurrency level
// how many browser should be started simultanous
concurrency: Infinity
})
}
4 changes: 4 additions & 0 deletions config.tpl.ls
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,7 @@ module.exports = (config) ->
# Continuous Integration mode
# if true, Karma captures browsers, runs the tests and exits
singleRun: false

# Concurrency level
# how many browser should be started simultanous
concurrency: Infinity
10 changes: 9 additions & 1 deletion docs/config/01-configuration-file.md
Original file line number Diff line number Diff line change
Expand Up @@ -369,7 +369,7 @@ Click <a href="preprocessors.html">here</a> for more information.
**Possible Values:**

* `http:`
* `https:`
* `https:`

**Description:** Protocol used for running the Karma webserver.

Expand Down Expand Up @@ -478,6 +478,14 @@ iFrame and may need a new window to run.
All of Karma's urls get prefixed with the `urlRoot`. This is helpful when using proxies, as
sometimes you might want to proxy a url that is already taken by Karma.

## concurrency
**Type:** Number

**Default:** `Infinity`

**Description:** How many browser Karma launches in parallel.

Especially on sevices like SauceLabs and Browserstack it makes sense to only launch a limited amount of browsers at once, and only start more when those have finished. Using this configuration you can sepcify how many browsers should be running at once at any given point in time.

[plugins]: plugins.html
[config/files]: files.html
Expand Down
1 change: 1 addition & 0 deletions lib/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,7 @@ var Config = function () {
this.browserDisconnectTimeout = 2000
this.browserDisconnectTolerance = 0
this.browserNoActivityTimeout = 10000
this.concurrency = Infinity
}

var CONFIG_SYNTAX_HELP = ' module.exports = function(config) {\n' +
Expand Down
40 changes: 33 additions & 7 deletions lib/launcher.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
var log = require('./logger').create('launcher')
var Promise = require('bluebird')
var cq = require('concurrent-queue')

var log = require('./logger').create('launcher')

var baseDecorator = require('./launchers/base').decoratorFactory
var captureTimeoutDecorator = require('./launchers/capture_timeout').decoratorFactory
Expand Down Expand Up @@ -31,9 +33,22 @@ var Launcher = function (emitter, injector) {
return null
}

this.launch = function (names, protocol, hostname, port, urlRoot) {
var browser
this.launch = function (names, protocol, hostname, port, urlRoot, concurrency) {
var url = protocol + '//' + hostname + ':' + port + urlRoot
var queue = cq().limit({concurrency: concurrency}).process(function (browser, done) {
log.info('Starting browser %s', browser.name)

browser.start(url)
browser.on('browser_process_failure', function () {
done(browser.error)
})

browser.on('done', function () {
if (browser.error) return

done(null, browser)
})
})

lastStartTime = Date.now()

Expand All @@ -54,7 +69,7 @@ var Launcher = function (emitter, injector) {
}

try {
browser = injector.createChild([locals], ['launcher:' + name]).get('launcher:' + name)
var browser = injector.createChild([locals], ['launcher:' + name]).get('launcher:' + name)
} catch (e) {
if (e.message.indexOf('No provider for "launcher:' + name + '"') !== -1) {
log.warn('Can not load "%s", it is not registered!\n ' +
Expand Down Expand Up @@ -84,15 +99,26 @@ var Launcher = function (emitter, injector) {
}
}

log.info('Starting browser %s', browser.name)
browser.start(url)
queue(browser, function (err) {
if (err) {
log.error(err)
}
})

browsers.push(browser)
})

return browsers
}

this.launch.$inject = ['config.browsers', 'config.protocol', 'config.hostname', 'config.port', 'config.urlRoot']
this.launch.$inject = [
'config.browsers',
'config.protocol',
'config.hostname',
'config.port',
'config.urlRoot',
'config.concurrency'
]

this.kill = function (id, callback) {
var browser = getBrowserById(id)
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,7 @@
"body-parser": "^1.12.4",
"chokidar": "^1.0.1",
"colors": "^1.1.0",
"concurrent-queue": "^7.0.1",
"connect": "^3.3.5",
"core-js": "^1.2.2",
"di": "^0.0.1",
Expand Down
Empty file added test/e2e/support/world.js
Empty file.
94 changes: 67 additions & 27 deletions test/unit/launcher.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import di from 'di'
import events from '../../lib/events'
import launcher from '../../lib/launcher'
import createMockTimer from './mocks/timer'
import _ from 'lodash'

// promise mock
var stubPromise = (obj, method, stubAction) => {
Expand Down Expand Up @@ -84,34 +85,70 @@ describe('launcher', () => {
})

describe('launch', () => {
it('should inject and start all browsers', () => {
l.launch(['Fake'], 'http:', 'localhost', 1234, '/root/')
it('should inject and start all browsers', done => {
l.launch(['Fake'], 'http:', 'localhost', 1234, '/root/', 1)

var browser = FakeBrowser._instances.pop()
expect(browser.start).to.have.been.calledWith('http://localhost:1234/root/')
expect(browser.id).to.equal(lastGeneratedId)
expect(browser.name).to.equal('Fake')
browser.start = url => {
expect(url).to.equal('http://localhost:1234/root/')
expect(browser.id).to.equal(lastGeneratedId)
expect(browser.name).to.equal('Fake')
done()
}
})

it('should allow launching a script', () => {
l.launch(['/usr/local/bin/special-browser'], 'http:', 'localhost', 1234, '/')
it('should allow launching a script', done => {
l.launch(['/usr/local/bin/special-browser'], 'http:', 'localhost', 1234, '/', 1)

var script = ScriptBrowser._instances.pop()
expect(script.start).to.have.been.calledWith('http://localhost:1234/')
expect(script.name).to.equal('/usr/local/bin/special-browser')
script.start = url => {
expect(url).to.equal('http://localhost:1234/')
expect(script.name).to.equal('/usr/local/bin/special-browser')
done()
}
})

it('should use the non default host', () => {
l.launch(['Fake'], 'http:', 'whatever', 1234, '/root/')
it('should use the non default host', done => {
l.launch(['Fake'], 'http:', 'whatever', 1234, '/root/', 1)

var browser = FakeBrowser._instances.pop()
expect(browser.start).to.have.been.calledWith('http://whatever:1234/root/')
browser.start = url => {
expect(url).to.equal('http://whatever:1234/root/')
done()
}
})

it('should only launch the specified number of browsers at once', done => {
l.launch([
'Fake',
'Fake',
'Fake'
], 'http:', 'whatever', 1234, '/root/', 2)

var start = sinon.stub()
var b1 = FakeBrowser._instances.pop()
var b2 = FakeBrowser._instances.pop()

b1.start = start
b2.start = start
FakeBrowser._instances.pop().start = start

_.defer(() => {
expect(start).to.have.been.calledTwice
b1._done()
b2._done()

_.defer(() => {
expect(start).to.have.been.calledThrice
done()
})
})
})
})

describe('restart', () => {
it('should restart the browser', () => {
l.launch(['Fake'], 'http:', 'localhost', 1234, '/root/')
l.launch(['Fake'], 'http:', 'localhost', 1234, '/root/', 1)
var browser = FakeBrowser._instances.pop()

var returnedValue = l.restart(lastGeneratedId)
Expand All @@ -120,14 +157,14 @@ describe('launcher', () => {
})

it('should return false if the browser was not launched by launcher (manual)', () => {
l.launch([], 'http:', 'localhost', 1234, '/')
l.launch([], 'http:', 'localhost', 1234, '/', 1)
expect(l.restart('manual-id')).to.equal(false)
})
})

describe('kill', () => {
it('should kill browser with given id', done => {
l.launch(['Fake'])
l.launch(['Fake'], 'http:', 'localhost', 1234, '/', 1)
var browser = FakeBrowser._instances.pop()

l.kill(browser.id, done)
Expand All @@ -137,7 +174,7 @@ describe('launcher', () => {
})

it('should return false if browser does not exist, but still resolve the callback', done => {
l.launch(['Fake'])
l.launch(['Fake'], 'http:', 'localhost', 1234, '/', 1)
var browser = FakeBrowser._instances.pop()

var returnedValue = l.kill('weird-id', done)
Expand All @@ -146,7 +183,7 @@ describe('launcher', () => {
})

it('should not require a callback', done => {
l.launch(['Fake'])
l.launch(['Fake'], 'http:', 'localhost', 1234, '/', 1)
FakeBrowser._instances.pop()

l.kill('weird-id')
Expand All @@ -156,7 +193,7 @@ describe('launcher', () => {

describe('killAll', () => {
it('should kill all running processe', () => {
l.launch(['Fake', 'Fake'], 'http:', 'localhost', 1234)
l.launch(['Fake', 'Fake'], 'http:', 'localhost', 1234, '/', 1)
l.killAll()

var browser = FakeBrowser._instances.pop()
Expand All @@ -169,7 +206,7 @@ describe('launcher', () => {
it('should call callback when all processes killed', () => {
var exitSpy = sinon.spy()

l.launch(['Fake', 'Fake'], 'http:', 'localhost', 1234)
l.launch(['Fake', 'Fake'], 'http:', 'localhost', 1234, '/', 1)
l.killAll(exitSpy)

expect(exitSpy).not.to.have.been.called
Expand Down Expand Up @@ -198,23 +235,26 @@ describe('launcher', () => {
})
})

describe('areAllCaptured', () => {
describe('areAllCaptured', done => {
it('should return true if only if all browsers captured', () => {
l.launch(['Fake', 'Fake'], 'http:', 'localhost', 1234)
l.launch(['Fake', 'Fake'], 'http:', 'localhost', 1234, '/', 2)

expect(l.areAllCaptured()).to.equal(false)
_.defer(() => {
expect(l.areAllCaptured()).to.equal(false)

l.markCaptured(1)
expect(l.areAllCaptured()).to.equal(false)
l.markCaptured(1)
expect(l.areAllCaptured()).to.equal(false)

l.markCaptured(2)
expect(l.areAllCaptured()).to.equal(true)
l.markCaptured(2)
expect(l.areAllCaptured()).to.equal(true)
done()
})
})
})

describe('onExit', () => {
it('should kill all browsers', done => {
l.launch(['Fake', 'Fake'], 'http:', 'localhost', 1234, '/', 0, 1)
l.launch(['Fake', 'Fake'], 'http:', 'localhost', 1234, '/', 1)

emitter.emitAsync('exit').then(done)

Expand Down

0 comments on commit 99696db

Please sign in to comment.