Merge pull request #230 from Block8/plugin-builder

Allow 3rd party plugins
This commit is contained in:
Steve B 2013-12-09 08:45:36 -08:00
commit 4e4607434c
9 changed files with 475 additions and 110 deletions

31
PHPCI/BuildLogger.php Normal file
View file

@ -0,0 +1,31 @@
<?php
namespace PHPCI;
use Psr\Log\LogLevel;
/**
* PHPCI Build Logger
*/
interface BuildLogger
{
/**
* Add an entry to the build log.
* @param string|string[] $message
* @param string $level
* @param mixed[] $context
*/
public function log($message, $level = LogLevel::INFO, $context = array());
/**
* Add a success-coloured message to the log.
* @param string
*/
public function logSuccess($message);
/**
* Add a failure-coloured message to the log.
* @param string $message
* @param \Exception $exception The exception that caused the error.
*/
public function logFailure($message, \Exception $exception = null);
}

View file

@ -21,7 +21,7 @@ use Psr\Log\LogLevel;
* PHPCI Build Runner
* @author Dan Cryer <dan@block8.co.uk>
*/
class Builder implements LoggerAwareInterface
class Builder implements LoggerAwareInterface, BuildLogger
{
/**
* @var string
@ -92,9 +92,9 @@ class Builder implements LoggerAwareInterface
public $quiet = false;
/**
* @var \PHPCI\Plugin\Util\Factory
* @var \PHPCI\Plugin\Util\Executor
*/
protected $pluginFactory;
protected $pluginExecutor;
/**
* Set up the builder.
@ -108,7 +108,7 @@ class Builder implements LoggerAwareInterface
}
$this->build = $build;
$this->store = Store\Factory::getStore('Build');
$this->setupPluginFactory($build);
$this->pluginExecutor = new Plugin\Util\Executor($this->buildPluginFactory($build), $this);
}
/**
@ -163,6 +163,7 @@ class Builder implements LoggerAwareInterface
$this->build->setStarted(new \DateTime());
$this->store->save($this->build);
$this->build->sendStatusPostback();
$this->success = true;
try {
// Set up the build:
@ -170,19 +171,19 @@ class Builder implements LoggerAwareInterface
// Run the core plugin stages:
foreach (array('setup', 'test', 'complete') as $stage) {
$this->executePlugins($stage);
$this->success &= $this->pluginExecutor->executePlugins($this->config, $stage);
}
// Failed build? Execute failure plugins and then mark the build as failed.
if (!$this->success) {
$this->executePlugins('failure');
$this->pluginExecutor->executePlugins($this->config, 'failure');
throw new \Exception('BUILD FAILED!');
}
// If we got this far, the build was successful!
if ($this->success) {
$this->build->setStatus(2);
$this->executePlugins('success');
$this->pluginExecutor->executePlugins($this->config, 'success');
$this->logSuccess('BUILD SUCCESSFUL!');
}
@ -372,79 +373,6 @@ class Builder implements LoggerAwareInterface
return true;
}
/**
* Execute a the appropriate set of plugins for a given build stage.
*/
protected function executePlugins($stage)
{
// Ignore any stages for which we don't have plugins set:
if (!array_key_exists(
$stage,
$this->config
) || !is_array($this->config[$stage])
) {
return;
}
foreach ($this->config[$stage] as $plugin => $options) {
$this->log('RUNNING PLUGIN: ' . $plugin);
// Is this plugin allowed to fail?
if ($stage == 'test' && !isset($options['allow_failures'])) {
$options['allow_failures'] = false;
}
// Try and execute it:
if ($this->executePlugin($plugin, $options)) {
// Execution was successful:
$this->logSuccess('PLUGIN STATUS: SUCCESS!');
} else {
// If we're in the "test" stage and the plugin is not allowed to fail,
// then mark the build as failed:
if ($stage == 'test' && !$options['allow_failures']) {
$this->success = false;
}
$this->logFailure('PLUGIN STATUS: FAILED');
}
}
}
/**
* Executes a given plugin, with options and returns the result.
*/
protected function executePlugin($plugin, $options)
{
// Figure out the class name and check the plugin exists:
$class = str_replace('_', ' ', $plugin);
$class = ucwords($class);
$class = 'PHPCI\\Plugin\\' . str_replace(' ', '', $class);
if (!class_exists($class)) {
$this->logFailure('Plugin does not exist: ' . $plugin, $ex);
return false;
}
$rtn = true;
// Try running it:
try {
$obj = $this->pluginFactory->buildPlugin($class, $options);
if (!$obj->execute()) {
$rtn = false;
}
} catch (\Exception $ex) {
$this->logFailure('EXCEPTION: ' . $ex->getMessage(), $ex);
$rtn = false;
}
return $rtn;
}
/**
* Find a binary required by a plugin.
* @param $binary
@ -499,12 +427,12 @@ class Builder implements LoggerAwareInterface
return $this->logger;
}
private function setupPluginFactory(Build $build)
private function buildPluginFactory(Build $build)
{
$this->pluginFactory = new Plugin\Util\Factory();
$pluginFactory = new Plugin\Util\Factory();
$self = $this;
$this->pluginFactory->registerResource(
$pluginFactory->registerResource(
function () use($self) {
return $self;
},
@ -512,7 +440,7 @@ class Builder implements LoggerAwareInterface
'PHPCI\Builder'
);
$this->pluginFactory->registerResource(
$pluginFactory->registerResource(
function () use($build) {
return $build;
},
@ -520,7 +448,7 @@ class Builder implements LoggerAwareInterface
'PHPCI\Model\Build'
);
$this->pluginFactory->registerResource(
$pluginFactory->registerResource(
function () use ($self) {
$factory = new MailerFactory($self->getSystemConfig('phpci'));
return $factory->getSwiftMailerFromConfig();
@ -528,5 +456,7 @@ class Builder implements LoggerAwareInterface
null,
'Swift_Mailer'
);
return $pluginFactory;
}
}

View file

@ -0,0 +1,108 @@
<?php
namespace PHPCI\Plugin\Util;
use PHPCI\BuildLogger;
class Executor
{
/**
* @var BuildLogger
*/
protected $logger;
/**
* @var Factory
*/
protected $pluginFactory;
function __construct(Factory $pluginFactory, BuildLogger $logger)
{
$this->pluginFactory = $pluginFactory;
$this->logger = $logger;
}
/**
* Execute a the appropriate set of plugins for a given build stage.
* @param array $config PHPCI configuration
* @param string $stage
* @return bool
*/
public function executePlugins(&$config, $stage)
{
$success = true;
// Ignore any stages for which we don't have plugins set:
if (!array_key_exists($stage, $config) || !is_array($config[$stage])) {
return $success;
}
foreach ($config[$stage] as $plugin => $options) {
$this->logger->log('RUNNING PLUGIN: ' . $plugin);
// Is this plugin allowed to fail?
if ($stage == 'test' && !isset($options['allow_failures'])) {
$options['allow_failures'] = false;
}
// Try and execute it:
if ($this->executePlugin($plugin, $options)) {
// Execution was successful:
$this->logger->logSuccess('PLUGIN STATUS: SUCCESS!');
} else {
// If we're in the "test" stage and the plugin is not allowed to fail,
// then mark the build as failed:
if ($stage == 'test' && !$options['allow_failures']) {
$success = false;
}
$this->logger->logFailure('PLUGIN STATUS: FAILED');
}
}
return $success;
}
/**
* Executes a given plugin, with options and returns the result.
*/
public function executePlugin($plugin, $options)
{
// Any plugin name without a namespace separator is a PHPCI built in plugin
// if not we assume it's a fully name-spaced class name that implements the plugin interface.
// If not the factory will throw an exception.
if (strpos($plugin, '\\') === false) {
$class = str_replace('_', ' ', $plugin);
$class = ucwords($class);
$class = 'PHPCI\\Plugin\\' . str_replace(' ', '', $class);
}
else {
$class = $plugin;
}
if (!class_exists($class)) {
$this->logger->logFailure('Plugin does not exist: ' . $plugin);
return false;
}
$rtn = true;
// Try running it:
try {
$obj = $this->pluginFactory->buildPlugin($class, $options);
if (!$obj->execute()) {
$rtn = false;
}
} catch (\Exception $ex) {
$this->logger->logFailure('EXCEPTION: ' . $ex->getMessage(), $ex);
$rtn = false;
}
return $rtn;
}
}

View file

@ -9,6 +9,8 @@ class Factory {
const TYPE_ARRAY = "array";
const TYPE_CALLABLE = "callable";
const INTERFACE_PHPCI_PLUGIN = '\PHPCI\Plugin';
private $currentPluginOptions;
/**
@ -45,6 +47,7 @@ class Factory {
*
* @param $className
* @param array $options
* @throws \InvalidArgumentException if $className doesn't represent a valid plugin
* @return \PHPCI\Plugin
*/
public function buildPlugin($className, array $options = array())
@ -53,6 +56,12 @@ class Factory {
$reflectedPlugin = new \ReflectionClass($className);
if (!$reflectedPlugin->implementsInterface(self::INTERFACE_PHPCI_PLUGIN)) {
throw new \InvalidArgumentException(
"Requested class must implement " . self:: INTERFACE_PHPCI_PLUGIN
);
}
$constructor = $reflectedPlugin->getConstructor();
if ($constructor) {

View file

@ -5,18 +5,28 @@ use PHPCI\Builder;
use PHPCI\Model\Build;
use PHPCI\Plugin;
class ExamplePluginWithNoConstructorArgs {
class ExamplePluginWithNoConstructorArgs implements Plugin
{
public function execute()
{
}
}
class ExamplePluginWithSingleOptionalArg {
class ExamplePluginWithSingleOptionalArg implements Plugin
{
function __construct($optional = null)
{
}
public function execute()
{
}
}
class ExamplePluginWithSingleRequiredArg {
class ExamplePluginWithSingleRequiredArg implements Plugin
{
public $RequiredArgument;
@ -24,9 +34,15 @@ class ExamplePluginWithSingleRequiredArg {
{
$this->RequiredArgument = $requiredArgument;
}
public function execute()
{
}
}
class ExamplePluginWithSingleTypedRequiredArg {
class ExamplePluginWithSingleTypedRequiredArg implements Plugin
{
public $RequiredArgument;
@ -34,6 +50,11 @@ class ExamplePluginWithSingleTypedRequiredArg {
{
$this->RequiredArgument = $requiredArgument;
}
public function execute()
{
}
}
class ExamplePluginFull implements Plugin {

View file

@ -0,0 +1,146 @@
<?php
namespace PHPCI\Plugin\Tests\Util;
require_once __DIR__ . "/ExamplePlugins.php";
use PHPCI\Plugin\Util\Executor;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTestCase;
class ExecutorTest extends ProphecyTestCase
{
/**
* @var Executor
*/
protected $testedExecutor;
protected $mockBuildLogger;
protected $mockFactory;
protected function setUp()
{
parent::setUp();
$this->mockBuildLogger = $this->prophesize('\PHPCI\BuildLogger');
$this->mockFactory = $this->prophesize('\PHPCI\Plugin\Util\Factory');
$this->testedExecutor = new Executor($this->mockFactory->reveal(), $this->mockBuildLogger->reveal());
}
public function testExecutePlugin_AssumesPHPCINamespaceIfNoneGiven()
{
$options = array();
$pluginName = 'PhpUnit';
$pluginNamespace = 'PHPCI\\Plugin\\';
$this->mockFactory->buildPlugin($pluginNamespace . $pluginName, $options)
->shouldBeCalledTimes(1)
->willReturn($this->prophesize('PHPCI\Plugin')->reveal());
$this->testedExecutor->executePlugin($pluginName, $options);
}
public function testExecutePlugin_KeepsCalledNameSpace()
{
$options = array();
$pluginName = 'ExamplePluginFull';
$pluginNamespace = '\\PHPCI\\Plugin\\Tests\\Util\\';
$this->mockFactory->buildPlugin($pluginNamespace . $pluginName, $options)
->shouldBeCalledTimes(1)
->willReturn($this->prophesize('PHPCI\Plugin')->reveal());
$this->testedExecutor->executePlugin($pluginNamespace . $pluginName, $options);
}
public function testExecutePlugin_CallsExecuteOnFactoryBuildPlugin()
{
$options = array();
$pluginName = 'PhpUnit';
$mockPlugin = $this->prophesize('PHPCI\Plugin');
$mockPlugin->execute()->shouldBeCalledTimes(1);
$this->mockFactory->buildPlugin(Argument::any(), Argument::any())->willReturn($mockPlugin->reveal());
$this->testedExecutor->executePlugin($pluginName, $options);
}
public function testExecutePlugin_ReturnsPluginSuccess()
{
$options = array();
$pluginName = 'PhpUnit';
$expectedReturnValue = true;
$mockPlugin = $this->prophesize('PHPCI\Plugin');
$mockPlugin->execute()->willReturn($expectedReturnValue);
$this->mockFactory->buildPlugin(Argument::any(), Argument::any())->willReturn($mockPlugin->reveal());
$returnValue = $this->testedExecutor->executePlugin($pluginName, $options);
$this->assertEquals($expectedReturnValue, $returnValue);
}
public function testExecutePlugin_LogsFailureForNonExistentClasses()
{
$options = array();
$pluginName = 'DOESNTEXIST';
$this->mockBuildLogger->logFailure('Plugin does not exist: ' . $pluginName)->shouldBeCalledTimes(1);
$this->testedExecutor->executePlugin($pluginName, $options);
}
public function testExecutePlugin_LogsFailureWhenExceptionsAreThrownByPlugin()
{
$options = array();
$pluginName = 'PhpUnit';
$expectedException = new \RuntimeException("Generic Error");
$mockPlugin = $this->prophesize('PHPCI\Plugin');
$mockPlugin->execute()->willThrow($expectedException);
$this->mockFactory->buildPlugin(Argument::any(), Argument::any())->willReturn($mockPlugin->reveal());
$this->mockBuildLogger->logFailure('EXCEPTION: ' . $expectedException->getMessage(), $expectedException)
->shouldBeCalledTimes(1);
$this->testedExecutor->executePlugin($pluginName, $options);
}
public function testExecutePlugins_CallsEachPluginForStage()
{
$phpUnitPluginOptions = array();
$behatPluginOptions = array();
$config = array(
'stageOne' => array(
'PhpUnit' => $phpUnitPluginOptions,
'Behat' => $behatPluginOptions,
)
);
$pluginNamespace = 'PHPCI\\Plugin\\';
$mockPhpUnitPlugin = $this->prophesize('PHPCI\Plugin');
$mockPhpUnitPlugin->execute()->shouldBeCalledTimes(1)->willReturn(true);
$this->mockFactory->buildPlugin($pluginNamespace . 'PhpUnit', $phpUnitPluginOptions)
->willReturn($mockPhpUnitPlugin->reveal());
$mockBehatPlugin = $this->prophesize('PHPCI\Plugin');
$mockBehatPlugin->execute()->shouldBeCalledTimes(1)->willReturn(true);
$this->mockFactory->buildPlugin($pluginNamespace . 'Behat', $behatPluginOptions)
->willReturn($mockBehatPlugin->reveal());
$this->testedExecutor->executePlugins($config, 'stageOne');
}
}

View file

@ -37,10 +37,16 @@ class FactoryTest extends \PHPUnit_Framework_TestCase {
public function testRegisterResourceThrowsExceptionWithoutTypeAndName()
{
$this->setExpectedException("InvalidArgumentException");
$this->setExpectedException('InvalidArgumentException', 'Type or Name must be specified');
$this->testedFactory->registerResource($this->resourceLoader, null, null);
}
public function testRegisterResourceThrowsExceptionIfLoaderIsntFunction()
{
$this->setExpectedException('InvalidArgumentException', '$loader is expected to be a function');
$this->testedFactory->registerResource(array("dummy"), "TestName", "TestClass");
}
public function testBuildPluginWorksWithConstructorlessPlugins()
{
$namespace = '\\PHPCI\\Plugin\\Tests\\Util\\';
@ -49,6 +55,12 @@ class FactoryTest extends \PHPUnit_Framework_TestCase {
$this->assertInstanceOf($expectedPluginClass, $plugin);
}
public function testBuildPluginFailsForNonPluginClasses()
{
$this->setExpectedException('InvalidArgumentException', 'Requested class must implement \PHPCI\Plugin');
$plugin = $this->testedFactory->buildPlugin("stdClass");
}
public function testBuildPluginWorksWithSingleOptionalArgConstructor()
{
$namespace = '\\PHPCI\\Plugin\\Tests\\Util\\';

View file

@ -33,6 +33,10 @@
"pimple/pimple": "1.1.*"
},
"require-dev": {
"phpspec/prophecy-phpunit": "1.*"
},
"suggest": {
"phpunit/phpunit": "PHP unit testing framework",
"phpmd/phpmd": "PHP Mess Detector",

142
composer.lock generated
View file

@ -3,7 +3,7 @@
"This file locks the dependencies of your project to a known state",
"Read more about it at http://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file"
],
"hash": "2f0615871ce4ee1eb8e4642bf0c731da",
"hash": "07f37de4c8bacd8a1a7b6e14269178d1",
"packages": [
{
"name": "block8/b8framework",
@ -239,16 +239,16 @@
},
{
"name": "swiftmailer/swiftmailer",
"version": "v5.0.2",
"version": "v5.0.3",
"source": {
"type": "git",
"url": "https://github.com/swiftmailer/swiftmailer.git",
"reference": "f3917ecef35a4e4d98b303eb9fee463bc983f379"
"reference": "32edc3b0de0fdc1b10f5c4912e8677b3f411a230"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/swiftmailer/swiftmailer/zipball/f3917ecef35a4e4d98b303eb9fee463bc983f379",
"reference": "f3917ecef35a4e4d98b303eb9fee463bc983f379",
"url": "https://api.github.com/repos/swiftmailer/swiftmailer/zipball/32edc3b0de0fdc1b10f5c4912e8677b3f411a230",
"reference": "32edc3b0de0fdc1b10f5c4912e8677b3f411a230",
"shasum": ""
},
"require": {
@ -284,21 +284,21 @@
"mail",
"mailer"
],
"time": "2013-08-30 12:35:21"
"time": "2013-12-03 13:33:24"
},
{
"name": "symfony/console",
"version": "v2.3.7",
"version": "v2.4.0",
"target-dir": "Symfony/Component/Console",
"source": {
"type": "git",
"url": "https://github.com/symfony/Console.git",
"reference": "00848d3e13cf512e77c7498c2b3b0192f61f4b18"
"reference": "3c1496ae96d24ccc6c340fcc25f71d7a1ab4c12c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/Console/zipball/00848d3e13cf512e77c7498c2b3b0192f61f4b18",
"reference": "00848d3e13cf512e77c7498c2b3b0192f61f4b18",
"url": "https://api.github.com/repos/symfony/Console/zipball/3c1496ae96d24ccc6c340fcc25f71d7a1ab4c12c",
"reference": "3c1496ae96d24ccc6c340fcc25f71d7a1ab4c12c",
"shasum": ""
},
"require": {
@ -313,7 +313,7 @@
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.3-dev"
"dev-master": "2.4-dev"
}
},
"autoload": {
@ -337,21 +337,21 @@
],
"description": "Symfony Console Component",
"homepage": "http://symfony.com",
"time": "2013-11-13 21:27:40"
"time": "2013-11-27 09:10:40"
},
{
"name": "symfony/yaml",
"version": "v2.3.7",
"version": "v2.4.0",
"target-dir": "Symfony/Component/Yaml",
"source": {
"type": "git",
"url": "https://github.com/symfony/Yaml.git",
"reference": "c1bda5b459d792cb253de12c65beba3040163b2b"
"reference": "1ae235a1b9d3ad3d9f3860ff20acc072df95b7f5"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/Yaml/zipball/c1bda5b459d792cb253de12c65beba3040163b2b",
"reference": "c1bda5b459d792cb253de12c65beba3040163b2b",
"url": "https://api.github.com/repos/symfony/Yaml/zipball/1ae235a1b9d3ad3d9f3860ff20acc072df95b7f5",
"reference": "1ae235a1b9d3ad3d9f3860ff20acc072df95b7f5",
"shasum": ""
},
"require": {
@ -360,7 +360,7 @@
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.3-dev"
"dev-master": "2.4-dev"
}
},
"autoload": {
@ -384,11 +384,115 @@
],
"description": "Symfony Yaml Component",
"homepage": "http://symfony.com",
"time": "2013-10-17 11:48:01"
"time": "2013-11-26 16:40:27"
}
],
"packages-dev": [
{
"name": "phpspec/prophecy",
"version": "v1.0.4",
"source": {
"type": "git",
"url": "https://github.com/phpspec/prophecy.git",
"reference": "79d9c8bd94801bffbf9b56964f6438762da6d8cd"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpspec/prophecy/zipball/79d9c8bd94801bffbf9b56964f6438762da6d8cd",
"reference": "79d9c8bd94801bffbf9b56964f6438762da6d8cd",
"shasum": ""
},
"require-dev": {
"phpspec/phpspec": "2.0.*"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.0.x-dev"
}
},
"autoload": {
"psr-0": {
"Prophecy\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Konstantin Kudryashov",
"email": "ever.zet@gmail.com",
"homepage": "http://everzet.com"
},
{
"name": "Marcello Duarte",
"email": "marcello.duarte@gmail.com"
}
],
"description": "Highly opinionated mocking framework for PHP 5.3+",
"homepage": "http://phpspec.org",
"keywords": [
"Double",
"Dummy",
"fake",
"mock",
"spy",
"stub"
],
"time": "2013-08-10 11:11:45"
},
{
"name": "phpspec/prophecy-phpunit",
"version": "v1.0.0",
"target-dir": "Prophecy/PhpUnit",
"source": {
"type": "git",
"url": "https://github.com/phpspec/prophecy-phpunit.git",
"reference": "ebc983be95b026fcea18afb7870e7b9041dc9d11"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpspec/prophecy-phpunit/zipball/ebc983be95b026fcea18afb7870e7b9041dc9d11",
"reference": "ebc983be95b026fcea18afb7870e7b9041dc9d11",
"shasum": ""
},
"require": {
"phpspec/prophecy": "~1.0"
},
"suggest": {
"phpunit/phpunit": "if it is not installed globally"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.0.x-dev"
}
},
"autoload": {
"psr-0": {
"Prophecy\\PhpUnit\\": ""
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Christophe Coevoet",
"email": "stof@notk.org"
}
],
"description": "PhpUnit test case integrating the Prophecy mocking library",
"homepage": "http://phpspec.org",
"keywords": [
"phpunit",
"prophecy"
],
"time": "2013-07-04 21:27:53"
}
],
"aliases": [