Skip to content

Commit de65b7e

Browse files
experimental: parallelization (#1018)
1 parent b7de87e commit de65b7e

12 files changed

+340
-15
lines changed

CHANGELOG.md

+2-1
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@ Please see [CONTRIBUTING.md](https://github.com/cucumber/cucumber/blob/master/CO
1212

1313
* can now use glob patterns for selecting what features to run
1414
* update `--require` to support glob patterns
15-
* add `--require-module` to require node modules before support code is loaded
15+
* add `--require-module <NODE_MODULE>` to require node modules before support code is loaded
1616
* add snippet interface "async-await"
17+
* add `--parallel <NUMBER_OF_SLAVES>` option to run tests in parallel. Note this is an experimental feature. See [here](/docs/cli.md#parallel-experimental) for more information
1718

1819
#### Deprecations
1920

bin/run_slave

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
#!/usr/bin/env node
2+
3+
require('../lib/runtime/parallel/run_slave.js').default()

docs/cli.md

+7
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,13 @@ This is useful when one needs to rerun failed tests locally by copying a line fr
9696
The default separator is a newline character.
9797
Note that the rerun file parser can only work with the default separator for now.
9898

99+
## Parallel (experimental)
100+
101+
You can run your scenarios in parallel with `--parallel <NUMBER_OF_SLAVES>`. Each slave is run in a separate node process and receives the following env variables:
102+
* `CUCUMBER_PARALLEL` - set to 'true'
103+
* `CUCUMBER_TOTAL_SLAVES` - set to the number of slaves
104+
* `CUCUMBER_SLAVE_ID` - id for slave ('0', '1', '2', etc)
105+
99106
## Profiles
100107

101108
In order to store and reuse commonly used CLI options, you can add a `cucumber.js` file to your project root directory. The file should export an object where the key is the profile name and the value is a string of CLI options. The profile can be applied with `-p <NAME>` or `--profile <NAME>`. This will prepend the profile's CLI options to the ones provided by the command line. Multiple profiles can be specified at a time. If no profile is specified and a profile named `default` exists, it will be applied.

src/cli/argv_parser.js

+6
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,12 @@ export default class ArgvParser {
8888
ArgvParser.collect,
8989
[]
9090
)
91+
.option(
92+
'--parallel <NUMBER_OF_SLAVES>',
93+
'run in parallel with the given number of slaves',
94+
parseInt,
95+
0
96+
)
9197
.option(
9298
'-r, --require <GLOB|DIR|FILE>',
9399
'require files before executing features (repeatable)',

src/cli/configuration_builder.js

+3-2
Original file line numberDiff line numberDiff line change
@@ -39,14 +39,14 @@ export default class ConfigurationBuilder {
3939
'.js'
4040
)
4141
}
42-
this.options.requireModule.forEach(module => require(module))
4342
return {
4443
featureDefaultLanguage: this.options.language,
4544
featurePaths,
4645
formats: this.getFormats(),
4746
formatOptions: this.getFormatOptions(),
4847
listI18nKeywordsFor,
4948
listI18nLanguages,
49+
parallel: this.options.parallel,
5050
profiles: this.options.profile,
5151
pickleFilterOptions: {
5252
featurePaths: unexpandedFeaturePaths,
@@ -61,7 +61,8 @@ export default class ConfigurationBuilder {
6161
worldParameters: this.options.worldParameters
6262
},
6363
shouldExitImmediately: !!this.options.exit,
64-
supportCodePaths
64+
supportCodePaths,
65+
supportCodeRequiredModules: this.options.requireModule
6566
}
6667
}
6768

src/cli/configuration_builder_spec.js

+3-1
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ describe('Configuration', function() {
3333
formats: [{ outputTo: '', type: 'progress' }],
3434
listI18nKeywordsFor: '',
3535
listI18nLanguages: false,
36+
parallel: 0,
3637
pickleFilterOptions: {
3738
featurePaths: ['features/**/*.feature'],
3839
names: [],
@@ -47,7 +48,8 @@ describe('Configuration', function() {
4748
worldParameters: {}
4849
},
4950
shouldExitImmediately: false,
50-
supportCodePaths: []
51+
supportCodePaths: [],
52+
supportCodeRequiredModules: []
5153
})
5254
})
5355
})

src/cli/index.js

+28-11
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import fs from 'mz/fs'
99
import path from 'path'
1010
import PickleFilter from '../pickle_filter'
1111
import Promise from 'bluebird'
12+
import ParallelRuntimeMaster from '../runtime/parallel/master'
1213
import Runtime from '../runtime'
1314
import supportCodeLibraryBuilder from '../support_code_library_builder'
1415

@@ -56,7 +57,8 @@ export default class Cli {
5657
}
5758
}
5859

59-
getSupportCodeLibrary(supportCodePaths) {
60+
getSupportCodeLibrary({ supportCodeRequiredModules, supportCodePaths }) {
61+
supportCodeRequiredModules.map(module => require(module))
6062
supportCodeLibraryBuilder.reset(this.cwd)
6163
supportCodePaths.forEach(codePath => require(codePath))
6264
return supportCodeLibraryBuilder.finalize()
@@ -73,9 +75,7 @@ export default class Cli {
7375
this.stdout.write(I18n.getKeywords(configuration.listI18nKeywordsFor))
7476
return { success: true }
7577
}
76-
const supportCodeLibrary = this.getSupportCodeLibrary(
77-
configuration.supportCodePaths
78-
)
78+
const supportCodeLibrary = this.getSupportCodeLibrary(configuration)
7979
const eventBroadcaster = new EventEmitter()
8080
const cleanup = await this.initializeFormatters({
8181
eventBroadcaster,
@@ -90,13 +90,30 @@ export default class Cli {
9090
featurePaths: configuration.featurePaths,
9191
pickleFilter: new PickleFilter(configuration.pickleFilterOptions)
9292
})
93-
const runtime = new Runtime({
94-
eventBroadcaster,
95-
options: configuration.runtimeOptions,
96-
supportCodeLibrary,
97-
testCases
98-
})
99-
const success = await runtime.start()
93+
let success
94+
if (configuration.parallel) {
95+
const parallelRuntimeMaster = new ParallelRuntimeMaster({
96+
eventBroadcaster,
97+
options: configuration.runtimeOptions,
98+
supportCodePaths: configuration.supportCodePaths,
99+
supportCodeRequiredModules: configuration.supportCodeRequiredModules,
100+
testCases
101+
})
102+
await new Promise(resolve => {
103+
parallelRuntimeMaster.run(configuration.parallel, s => {
104+
success = s
105+
resolve()
106+
})
107+
})
108+
} else {
109+
const runtime = new Runtime({
110+
eventBroadcaster,
111+
options: configuration.runtimeOptions,
112+
supportCodeLibrary,
113+
testCases
114+
})
115+
success = await runtime.start()
116+
}
100117
await cleanup()
101118
return {
102119
shouldExitImmediately: configuration.shouldExitImmediately,

src/runtime/parallel/README.md

+23
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
Parallelization is achieved by having multiple child processes running scenarios.
2+
3+
#### Master
4+
- load all features, generate test cases
5+
- broadcast `test-run-started`
6+
- create slaves and for each slave
7+
- send an `initialize` command
8+
- when a slave outputs a `ready` command, send it a `run` command with a test case. If there are no more test cases, send a `finalize` command
9+
- when a slave outputs an `event` command,
10+
broadcast the event to the formatters,
11+
and on `test-case-finished` update the overall result
12+
- when all slaves have exited, broadcast `test-run-finished`
13+
14+
#### Slave
15+
- when receiving the `initialize` command
16+
- load the support code and runs `BeforeAll` hooks
17+
- output the `ready` command
18+
- when receiving a `run` command
19+
- run the given testCase, outputting `event` commands
20+
- output the `ready` command
21+
- when receiving the `finalize` command
22+
- run the `AfterAll` hooks
23+
- exit

src/runtime/parallel/command_types.js

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
const commandTypes = {
2+
INITIALIZE: 'initialize',
3+
RUN: 'run',
4+
READY: 'ready',
5+
FINALIZE: 'finalize',
6+
EVENT: 'event'
7+
}
8+
9+
export default commandTypes

src/runtime/parallel/master.js

+132
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import _ from 'lodash'
2+
import childProcess from 'child_process'
3+
import commandTypes from './command_types'
4+
import path from 'path'
5+
import readline from 'readline'
6+
import Status from '../../status'
7+
8+
const slaveCommand = path.resolve(
9+
__dirname,
10+
'..',
11+
'..',
12+
'..',
13+
'bin',
14+
'run_slave'
15+
)
16+
17+
export default class Master {
18+
// options - {dryRun, failFast, filterStacktraces, strict}
19+
constructor({
20+
eventBroadcaster,
21+
options,
22+
supportCodePaths,
23+
supportCodeRequiredModules,
24+
testCases
25+
}) {
26+
this.eventBroadcaster = eventBroadcaster
27+
this.options = options || {}
28+
this.supportCodePaths = supportCodePaths
29+
this.supportCodeRequiredModules = supportCodeRequiredModules
30+
this.testCases = testCases || []
31+
this.nextTestCaseIndex = 0
32+
this.testCasesCompleted = 0
33+
this.result = {
34+
duration: 0,
35+
success: true
36+
}
37+
this.slaves = {}
38+
}
39+
40+
parseSlaveLine(slave, line) {
41+
const input = JSON.parse(line)
42+
switch (input.command) {
43+
case commandTypes.READY:
44+
this.giveSlaveWork(slave)
45+
break
46+
case commandTypes.EVENT:
47+
this.eventBroadcaster.emit(input.name, input.data)
48+
if (input.name === 'test-case-finished') {
49+
this.parseTestCaseResult(input.data.result)
50+
}
51+
break
52+
default:
53+
throw new Error(`Unexpected message from slave: ${line}`)
54+
}
55+
}
56+
57+
startSlave(id, total) {
58+
const slaveProcess = childProcess.spawn(slaveCommand, [], {
59+
env: _.assign({}, process.env, {
60+
CUCUMBER_PARALLEL: 'true',
61+
CUCUMBER_TOTAL_SLAVES: total,
62+
CUCUMBER_SLAVE_ID: id
63+
}),
64+
stdio: ['pipe', 'pipe', process.stderr]
65+
})
66+
const rl = readline.createInterface({ input: slaveProcess.stdout })
67+
const slave = { process: slaveProcess }
68+
this.slaves[id] = slave
69+
rl.on('line', line => {
70+
this.parseSlaveLine(slave, line)
71+
})
72+
rl.on('close', () => {
73+
slave.closed = true
74+
this.onSlaveClose()
75+
})
76+
slave.process.stdin.write(
77+
JSON.stringify({
78+
command: commandTypes.INITIALIZE,
79+
filterStacktraces: this.options.filterStacktraces,
80+
supportCodePaths: this.supportCodePaths,
81+
supportCodeRequiredModules: this.supportCodeRequiredModules,
82+
worldParameters: this.options.worldParameters
83+
}) + '\n'
84+
)
85+
}
86+
87+
onSlaveClose() {
88+
if (_.every(this.slaves, 'closed')) {
89+
this.eventBroadcaster.emit('test-run-finished', { result: this.result })
90+
this.onFinish(this.result.success)
91+
}
92+
}
93+
94+
parseTestCaseResult(testCaseResult) {
95+
this.testCasesCompleted += 1
96+
if (testCaseResult.duration) {
97+
this.result.duration += testCaseResult.duration
98+
}
99+
if (this.shouldCauseFailure(testCaseResult.status)) {
100+
this.result.success = false
101+
}
102+
}
103+
104+
run(numberOfSlaves, done) {
105+
this.eventBroadcaster.emit('test-run-started')
106+
_.times(numberOfSlaves, id => this.startSlave(id, numberOfSlaves))
107+
this.onFinish = done
108+
}
109+
110+
giveSlaveWork(slave) {
111+
if (this.nextTestCaseIndex === this.testCases.length) {
112+
slave.process.stdin.write(
113+
JSON.stringify({ command: commandTypes.FINALIZE }) + '\n'
114+
)
115+
return
116+
}
117+
const testCase = this.testCases[this.nextTestCaseIndex]
118+
this.nextTestCaseIndex += 1
119+
const skip =
120+
this.options.dryRun || (this.options.failFast && !this.result.success)
121+
slave.process.stdin.write(
122+
JSON.stringify({ command: commandTypes.RUN, skip, testCase }) + '\n'
123+
)
124+
}
125+
126+
shouldCauseFailure(status) {
127+
return (
128+
_.includes([Status.AMBIGUOUS, Status.FAILED, Status.UNDEFINED], status) ||
129+
(status === Status.PENDING && this.options.strict)
130+
)
131+
}
132+
}

src/runtime/parallel/run_slave.js

+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import Slave from './slave'
2+
3+
export default async function run() {
4+
const slave = new Slave({
5+
stdin: process.stdin,
6+
stdout: process.stdout,
7+
cwd: process.cwd()
8+
})
9+
await slave.run()
10+
}

0 commit comments

Comments
 (0)