diff --git a/PHPCI/Plugin/Paratest.php b/PHPCI/Plugin/Paratest.php
new file mode 100644
index 00000000..7901da13
--- /dev/null
+++ b/PHPCI/Plugin/Paratest.php
@@ -0,0 +1,112 @@
+xmlConfigFile) && empty($this->directory)) {
+ $this->phpci->logFailure('Neither configuration file nor test directory found.');
+ return false;
+ }
+
+ $success = true;
+
+ $this->phpci->logExecOutput(false);
+
+ // Run any config files first. This can be either a single value or an array.
+ if ($this->xmlConfigFile !== null) {
+ $success &= $this->runConfigFile($this->xmlConfigFile);
+ }
+
+ // Run any dirs next. Again this can be either a single value or an array.
+ if ($this->directory !== null) {
+ $success &= $this->runDir($this->directory);
+ }
+
+ //check output from JUnit log file
+ $xml_string = file_get_contents($this->phpci->buildPath . DIRECTORY_SEPARATOR . $this->log_file);
+
+ try {
+ $junit_parser = new JUnitParser($xml_string);
+ $output = $junit_parser->parse();
+ } catch (\Exception $ex) {
+ $this->phpci->logFailure($xml_string);
+ throw $ex;
+ }
+
+ $failures = $junit_parser->getTotalFailures();
+
+ $this->build->storeMeta('paratest-errors', $failures);
+ $this->build->storeMeta('paratest-data', $output);
+
+ $this->phpci->logExecOutput(true);
+
+ return $success;
+ }
+
+ /**
+ * Run the tests defined in a PHPUnit config file.
+ * @param $configPath
+ * @return bool|mixed
+ */
+ protected function runConfigFile($configPath)
+ {
+ if (is_array($configPath)) {
+ return $this->recurseArg($configPath, array($this, "runConfigFile"));
+ } else {
+ if ($this->runFrom) {
+ $curdir = getcwd();
+ chdir($this->phpci->buildPath . DIRECTORY_SEPARATOR . $this->runFrom);
+ }
+
+ $paratest = $this->phpci->findBinary('paratest');
+
+ $cmd = $paratest . ' -c phpunit-parallel.xml -p 4 --stop-on-failure --log-junit ' . $this->log_file;
+ $success = $this->phpci->executeCommand($cmd, $this->args, $this->phpci->buildPath . $configPath);
+
+ if ($this->runFrom) {
+ chdir($curdir);
+ }
+
+ return $success;
+ }
+ }
+
+ /**
+ * Run the PHPUnit tests in a specific directory or array of directories.
+ * @param $directory
+ * @return bool|mixed
+ */
+ protected function runDir($directory)
+ {
+ if (is_array($directory)) {
+ return $this->recurseArg($directory, array($this, "runDir"));
+ } else {
+ $curdir = getcwd();
+ chdir($this->phpci->buildPath);
+
+ $paratest = $this->phpci->findBinary('paratest');
+
+ $cmd = $paratest . ' -c phpunit-parallel.xml -p 4 --stop-on-failure --log-junit ' . $this->log_file;
+ $success = $this->phpci->executeCommand($cmd, $this->args, $this->phpci->buildPath . $directory);
+ chdir($curdir);
+ return $success;
+ }
+ }
+}
diff --git a/PHPCI/Plugin/Util/JUnitParser.php b/PHPCI/Plugin/Util/JUnitParser.php
new file mode 100644
index 00000000..7a6dd67e
--- /dev/null
+++ b/PHPCI/Plugin/Util/JUnitParser.php
@@ -0,0 +1,134 @@
+xml = new SimpleXMLElement($xml_string);
+ }
+
+ /**
+ * @return array An array of key/value pairs for storage in the plugins result metadata
+ */
+ public function parse()
+ {
+ $attr = $this->xml->testsuite->attributes();
+ $this->duration = floatval($attr['time']);
+ $this->number_tests = intval($attr['tests']);
+ $this->failures = intval($attr['failures']);
+
+ $raw_data = $this->parseTestSuites($this->xml);
+
+ //we want to log individual test cases in JSON format for meta data and flatten results
+ $data = [];
+ foreach ($raw_data as $suites) {
+ foreach ($suites as $suite) {
+ $suite_name = $suite['name'];
+ foreach ($suite['cases'] as $test_case) {
+ $result = [
+ 'pass' => true,
+ 'message' => $suite_name . '::' . $test_case['name'],
+ 'severity' => 'success',
+ ];
+
+ if (isset($test_case['error'])) {
+ $result = [
+ 'pass' => false,
+ 'message' => $test_case['error']['message'],
+ 'severity' => strpos($test_case['error']['type'], 'Error') !== false ? 'error' : 'fail'
+ ];
+ }
+
+ $data[] = $result;
+ }
+ }
+ }
+
+ return $data;
+ }
+
+ public function getTotalTests()
+ {
+ return $this->number_tests;
+ }
+
+ public function getTotalTimeTaken()
+ {
+ return $this->duration;
+ }
+
+ public function getTotalFailures()
+ {
+ return $this->failures;
+ }
+
+ //NOTE: this is modified from https://github.com/Block8/PHPCI/blob/2ddda7711e1272cf6591f274e09d45b9865f4a60/PHPCI/Plugin/PhpSpec.php
+ protected function parseTestSuites(SimpleXMLElement $xml)
+ {
+ $data = [];
+
+ /**
+ * @var \SimpleXMLElement $group
+ */
+ foreach ($xml->testsuite->testsuite as $group) {
+ $attr = $group->attributes();
+
+ $suite = array(
+ 'name' => (String)$attr['name'],
+ 'time' => (float)$attr['time'],
+ 'tests' => (int)$attr['tests'],
+ 'failures' => (int)$attr['failures'],
+ 'errors' => (int)$attr['errors'],
+ // now the cases
+ 'cases' => array()
+ );
+
+ /**
+ * @var \SimpleXMLElement $child
+ */
+ foreach ($group->testcase as $child) {
+ $attr = $child->attributes();
+
+ $case = array(
+ 'name' => (String)$attr['name'],
+ 'classname' => (String)$attr['class'],
+ 'filename' => (String)$attr['file'],
+ 'line' => (String)$attr['line'],
+ 'time' => (float)$attr['time'],
+ );
+
+ $error = [];
+ foreach ($child->failure as $f) {
+ $error['type'] = $f->attributes()['type'];
+ $error['message'] = (String)$f;
+ }
+ foreach ($child->{'system-err'} as $system_err) {
+ $error['raw'] = (String)$system_err;
+ }
+
+ if (!empty($error)) {
+ $case['error'] = $error;
+ }
+
+ $suite['cases'][] = $case;
+ }
+ $data['suites'][] = $suite;
+ }
+
+ return $data;
+ }
+}
diff --git a/public/assets/css/AdminLTE-custom.css b/public/assets/css/AdminLTE-custom.css
index ff75c31a..277a8709 100644
--- a/public/assets/css/AdminLTE-custom.css
+++ b/public/assets/css/AdminLTE-custom.css
@@ -82,3 +82,10 @@
#phpunit-data .error td { background: none; color: #f56954; }
#phpunit-data .skipped td { background: none; color: #e08e0b; }
#phpunit-data .todo td { background: none; color: #00c0ef; }
+
+#paratest-data th div { margin: 0 0.5em; }
+#paratest-data .success td { background: none; color: #00a65a; }
+#paratest-data .fail td { background: none; color: #f56954; }
+#paratest-data .error td { background: none; color: #f56954; }
+#paratest-data .skipped td { background: none; color: #e08e0b; }
+#paratest-data .todo td { background: none; color: #00c0ef; }
\ No newline at end of file
diff --git a/public/assets/js/paratest.js b/public/assets/js/paratest.js
new file mode 100644
index 00000000..3b3de00e
--- /dev/null
+++ b/public/assets/js/paratest.js
@@ -0,0 +1,148 @@
+//NOTE: modified from phpunitPlugin
+var paratestPlugin = ActiveBuild.UiPlugin.extend({
+ id: 'build-paratest-errors',
+ css: 'col-lg-6 col-md-12 col-sm-12 col-xs-12',
+ title: 'Paratest',
+ lastData: null,
+ displayOnUpdate: false,
+ box: true,
+ rendered: false,
+ statusMap: {
+ success : 'ok',
+ fail: 'remove',
+ error: 'warning-sign',
+ todo: 'info-sign',
+ skipped: 'exclamation-sign'
+ },
+
+ register: function() {
+ var self = this;
+ var query = ActiveBuild.registerQuery('paratest-data', -1, {key: 'paratest-data'})
+
+ $(window).on('paratest-data', function(data) {
+ self.onUpdate(data);
+ });
+
+ $(window).on('build-updated', function() {
+ if (!self.rendered) {
+ self.displayOnUpdate = true;
+ query();
+ }
+ });
+
+ $(document).on('click', '#paratest-data .test-toggle', function(ev) {
+ var input = $(ev.target);
+ $('#paratest-data tbody ' + input.data('target')).toggle(input.prop('checked'));
+ });
+ },
+
+ render: function() {
+
+ return $('
' +
+ '' +
+ '' +
+ ' '+Lang.get('test_message')+' | ' +
+ '
' +
+ '
');
+ },
+
+ onUpdate: function(e) {
+ if (!e.queryData) {
+ $('#build-paratest-errors').hide();
+ return;
+ }
+
+ this.rendered = true;
+ this.lastData = e.queryData;
+
+ var tests = this.lastData[0].meta_value;
+ var thead = $('#paratest-data thead tr');
+ var tbody = $('#paratest-data tbody');
+ thead.empty().append(''+Lang.get('test_message')+' | ');
+ tbody.empty();
+
+ if (tests.length == 0) {
+ $('#build-paratest-errors').hide();
+ return;
+ }
+
+ var counts = { success: 0, fail: 0, error: 0, skipped: 0, todo: 0 }, total = 0;
+
+ for (var i in tests) {
+ var content = $(' | '),
+ message = $('').appendTo(content),
+ severity = tests[i].severity || (tests[i].pass ? 'success' : 'failed');
+
+ if (tests[i].message) {
+ message.text(tests[i].message);
+ } else if (tests[i].test && tests[i].suite) {
+ message.text(tests[i].suite + '::' + tests[i].test);
+ } else {
+ message.html('' + Lang.get('test_no_message') + '');
+ }
+
+ if (tests[i].data) {
+ content.append('' + this.repr(tests[i].data) + '
');
+ }
+
+ $('
').append(content).appendTo(tbody);
+
+ counts[severity]++;
+ total++;
+ }
+
+ var checkboxes = $(' | ');
+ thead.append(checkboxes).append('' + Lang.get('test_total', total) + ' | ');
+
+ for (var key in counts) {
+ var count = counts[key];
+ if(count > 0) {
+ checkboxes.append(
+ ' ' +
+ Lang.get('test_'+key, count)+ '
'
+ );
+ }
+ }
+
+ tbody.find('.success').hide();
+
+ $('#build-paratest-errors').show();
+ },
+
+ repr: function(data)
+ {
+ switch(typeof(data)) {
+ case 'boolean':
+ return '' + (data ? 'true' : 'false') + '';
+ case 'string':
+ return '"' + data + '"';
+ case 'undefined': case null:
+ return 'null';
+ case 'object':
+ var rows = [];
+ if(data instanceof Array) {
+ for(var i in data) {
+ rows.push('' + this.repr(data[i]) + ', |
');
+ }
+ } else {
+ for(var key in data) {
+ rows.push(
+ '' +
+ '' + this.repr(key) + ' | ' +
+ '=> | ' +
+ '' + this.repr(data[key]) + ', | ' +
+ '
');
+ }
+ }
+ return '' +
+ 'array( |
' +
+ rows.join('') +
+ ') |
' +
+ '
';
+ }
+ return '???';
+ }
+});
+
+ActiveBuild.registerPlugin(new paratestPlugin());
\ No newline at end of file