diff --git a/src/Context/BasicContext.php b/src/Context/BasicContext.php index 536ff8e..0d97ad7 100644 --- a/src/Context/BasicContext.php +++ b/src/Context/BasicContext.php @@ -17,6 +17,7 @@ use Behat\Testwork\Tester\Result\TestResult; use Exception; use SilverStripe\Assets\File; use SilverStripe\Assets\Filesystem; +use SilverStripe\BehatExtension\Utility\StepHelper; use WebDriver\Exception as WebDriverException; use WebDriver\Session as WebDriverSession; @@ -31,13 +32,7 @@ use WebDriver\Session as WebDriverSession; class BasicContext implements Context { use MainContextAwareTrait; - - /** - * Work-around for https://github.com/Behat/Behat/issues/653 - * - * @var ScenarioNode - */ - protected $currentScenario = null; + use StepHelper; /** * Date format in date() syntax @@ -71,28 +66,6 @@ class BasicContext implements Context return $context->getSession($name); } - /** - * Work-around for https://github.com/Behat/Behat/issues/653 - * - * @BeforeScenario - * @param BeforeScenarioScope $event - */ - public function handleScenarioBegin(BeforeScenarioScope $event) - { - $this->currentScenario = $event->getScenario(); - } - - /** - * Work-around for https://github.com/Behat/Behat/issues/653 - * - * @AfterScenario - * @param AfterScenarioScope $event - */ - public function handleScenarioEnd(AfterScenarioScope $event) - { - $this->currentScenario = null; - } - /** * @AfterStep * @@ -447,11 +420,11 @@ JS; */ public function iShouldSeeAButton($negative, $text) { - $matchedEl = $this->findNamedButton($text); + $button = $this->findNamedButton($text); if (trim($negative)) { - assertNull($matchedEl, sprintf('%s button found', $text)); + assertNull($button, sprintf('%s button found', $text)); } else { - assertNotNull($matchedEl, sprintf('%s button not found', $text)); + assertNotNull($button, sprintf('%s button not found', $text)); } } @@ -1262,15 +1235,16 @@ JS; */ protected function stepHasTag(StepScope $event, $tag) { - // Check scenario - if ($this->currentScenario && $this->currentScenario->hasTag($tag)) { - return true; - } // Check feature $feature = $event->getFeature(); if ($feature && $feature->hasTag($tag)) { return true; } + // Check scenario + $scenario = $this->getStepScenario($feature, $event->getStep()); + if ($scenario && $scenario->hasTag($tag)) { + return true; + } return false; } } diff --git a/src/Context/LoginContext.php b/src/Context/LoginContext.php index 235bdfa..e0bc3d9 100644 --- a/src/Context/LoginContext.php +++ b/src/Context/LoginContext.php @@ -59,7 +59,7 @@ class LoginContext implements Context } /** - * @When /^I log in with "(?[^"]*)" and "(?[^"]*)"$/ + * @When /^I log in with "([^"]*)" and "([^"]*)"$/ * @param string $email * @param string $password */ diff --git a/src/Context/RetryableContextTrait.php b/src/Context/RetryableContextTrait.php deleted file mode 100644 index 79656cd..0000000 --- a/src/Context/RetryableContextTrait.php +++ /dev/null @@ -1,25 +0,0 @@ -= 0); - return null; - } -} diff --git a/src/Extension.php b/src/Extension.php index d03fef9..e6a5478 100644 --- a/src/Extension.php +++ b/src/Extension.php @@ -2,11 +2,13 @@ namespace SilverStripe\BehatExtension; +use Behat\Testwork\Call\ServiceContainer\CallExtension; use Behat\Testwork\Cli\ServiceContainer\CliExtension; use Behat\Testwork\Suite\Cli\InitializationController; use Behat\Testwork\Suite\ServiceContainer\SuiteExtension; use SilverStripe\BehatExtension\Controllers\ModuleInitialisationController; use SilverStripe\BehatExtension\Controllers\ModuleSuiteLocator; +use SilverStripe\BehatExtension\Utility\RetryableCallHandler; use Symfony\Component\Config\FileLocator; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Loader\YamlFileLoader; @@ -63,6 +65,7 @@ class Extension implements ExtensionInterface // Add CLI substitutions $this->loadSuiteLocator($container); $this->loadBootstrapController($container); + $this->loadCallHandlers($container, $config['error_reporting'], $config['retry_seconds']); // Set various paths $container->setParameter('silverstripe_extension.admin_url', $config['admin_url']); @@ -110,6 +113,14 @@ class Extension implements ExtensionInterface scalarNode('bootstrap_file')-> defaultNull()-> end()-> + scalarNode('error_reporting')-> + info('Call executor will catch exceptions matching this level')-> + defaultValue(E_ALL | E_STRICT)-> + end()-> + scalarNode('retry_seconds')-> + info('Number of seconds that @retry tags will retry for')-> + defaultValue(2)-> + end()-> arrayNode('ajax_steps')-> defaultValue(array( 'go to', @@ -161,4 +172,18 @@ class Extension implements ExtensionInterface $definition->addTag(CliExtension::CONTROLLER_TAG, ['priority' => 900]); $container->setDefinition(CliExtension::CONTROLLER_TAG . '.initialization', $definition); } + + /** + * Shivs in custom call handler + * + * @param ContainerBuilder $container + * @param integer $errorReporting + * @param int $retrySeconds + */ + protected function loadCallHandlers(ContainerBuilder $container, $errorReporting, $retrySeconds) + { + $definition = new Definition(RetryableCallHandler::class, [$errorReporting, $retrySeconds]); + $definition->addTag(CallExtension::CALL_HANDLER_TAG, ['priority' => 50]); + $container->setDefinition(CallExtension::CALL_HANDLER_TAG . '.runtime', $definition); + } } diff --git a/src/Utility/RetryableCallHandler.php b/src/Utility/RetryableCallHandler.php new file mode 100644 index 0000000..e7d832d --- /dev/null +++ b/src/Utility/RetryableCallHandler.php @@ -0,0 +1,192 @@ +errorReportingLevel = $errorReportingLevel; + $this->retrySeconds = $retrySeconds; + } + + /** + * {@inheritdoc} + */ + public function supportsCall(Call $call) + { + return true; + } + + /** + * {@inheritdoc} + */ + public function handleCall(Call $call) + { + $this->startErrorAndOutputBuffering($call); + $result = $this->executeCall($call); + $this->stopErrorAndOutputBuffering(); + + return $result; + } + + /** + * Used as a custom error handler when step is running. + * + * @see set_error_handler() + * + * @param integer $level + * @param string $message + * @param string $file + * @param integer $line + * + * @return Boolean + * + * @throws CallErrorException + */ + public function handleError($level, $message, $file, $line) + { + if ($this->errorLevelIsNotReportable($level)) { + return false; + } + + throw new CallErrorException($level, $message, $file, $line); + } + + /** + * Executes single call. + * + * @param Call $call + * + * @return CallResult + */ + private function executeCall(Call $call) + { + $callable = $call->getBoundCallable(); + $arguments = $call->getArguments(); + $retry = $this->isCallRetryable($call); + $return = $exception = null; + + try { + // Determine whether to call with retries + if ($retry) { + $return = $this->retryThrowable(function () use ($callable, $arguments) { + return call_user_func_array($callable, $arguments); + }, $this->retrySeconds); + } else { + $return = call_user_func_array($callable, $arguments); + } + } catch (Exception $caught) { + $exception = $caught; + } + + $stdOut = $this->getBufferedStdOut(); + + return new CallResult($call, $return, $exception, $stdOut); + } + + /** + * Returns buffered stdout. + * + * @return null|string + */ + private function getBufferedStdOut() + { + return ob_get_length() ? ob_get_contents() : null; + } + + /** + * Starts error handler and stdout buffering. + * + * @param Call $call + */ + private function startErrorAndOutputBuffering(Call $call) + { + $errorReporting = $call->getErrorReportingLevel() ? : $this->errorReportingLevel; + set_error_handler(array($this, 'handleError'), $errorReporting); + $this->obStarted = ob_start(); + } + + /** + * Stops error handler and stdout buffering. + */ + private function stopErrorAndOutputBuffering() + { + if ($this->obStarted) { + ob_end_clean(); + } + restore_error_handler(); + } + + /** + * Checks if provided error level is not reportable. + * + * @param integer $level + * + * @return Boolean + */ + private function errorLevelIsNotReportable($level) + { + return !(error_reporting() & $level); + } + + /** + * Determine if the call is retryable + * + * @param Call $call + * @return bool + */ + protected function isCallRetryable(Call $call) + { + if (!($call instanceof DefinitionCall)) { + return false; + } + $feature = $call->getFeature(); + if ($feature->hasTag(self::RETRY_TAG)) { + return true; + } + $scenario = $this->getStepScenario($feature, $call->getStep()); + return $scenario && $scenario->hasTag(self::RETRY_TAG); + } +} diff --git a/src/Utility/StepHelper.php b/src/Utility/StepHelper.php new file mode 100644 index 0000000..e7d87c3 --- /dev/null +++ b/src/Utility/StepHelper.php @@ -0,0 +1,63 @@ +getScenarios() as $nextScenario) { + if ($nextScenario->getLine() > $step->getLine()) { + break; + } + $scenario = $nextScenario; + } + return $scenario; + } + + /** + * Retry until no exceptions are thrown, or until + * $timeout seconds are reached. + * + * If timeout reached, re-throws the first exception. + * + * @param callable $callback + * @param int $timeout + * @return mixed + * @throws Exception + */ + protected function retryThrowable($callback, $timeout = 3) + { + $firstEx = null; + do { + try { + return call_user_func($callback); + } catch (Exception $ex) { + if (!$firstEx) { + $firstEx = $ex; + } + } + sleep(1); + } while (--$timeout >= 0); + throw $firstEx; + } +}