diff --git a/.gitignore b/.gitignore index b741226..04d68e0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ src/Behat -composer.lock \ No newline at end of file +composer.lock +results diff --git a/README.md b/README.md index aa0ef1c..11ac1be 100644 --- a/README.md +++ b/README.md @@ -1,86 +1,84 @@ -# Behat for Drupal 8 contribute components. +# Behat for Drupal 8. -The module provide BDD testing framework to contribute components. +The module provide BDD testing framework for contribute components. ## How it's works? -The Behat we all know and love built from a couple of symofony elements. This -module use gherkin library which read the cucumber files(AKA *.feature file). +Each project that uses behat as a testing tool have a FeatureContext class that +contains the step definition or any other additional information. -After tearing down the feature file to steps definition behat module will search -for plugins which their ID match the step. - -Eventually the behat module is just a layer which sit above the unit test -framework and trigger PHP commands. +Each module will need to to implement a FeatureContext plugin that will keep the +step definitions and other behat integration(i.e beforeScenario or afterScenario +methods). ## Define a plugin -Defining a plugin is very easy. All you need to do is to implement Behat step -definition plugin. For example the `I visit PATH` plugin: +Behat module implements a FeatureContext plugin: ```php visit($url); - } - -} -``` - -In the feature file it look like this: -```cucumber - Given I visit 'user' -``` - -## Define a test -The test definition is quite easy as well. Create a file under the `Tests` -folder: -```php -drupalCreateUser(); - $this - ->setPlaceholder('@user-name', $account->label()) - ->setPlaceholder('@user-pass', $account->passRaw) - ->executeScenario('login', 'behat'); + $this->placeholders = [ + '@user-name' => $account->label(), + '@user-pass' => $account->passRaw, + ]; } - } ``` -You can see there are arguments passed to the steps definition via `$this->setPlaceholder()` -This wll use us later on in the cucumber files. The key method is `$this->executeScenario()` -Which invoke a file named `login.feature` under the behat module. If you need to -invoke feature file to a theme component you'll need to write -`$this->executeScenario('collapsed', 'bootstrap', 'theme');` +This plugin implements a `beforeScenario` method to a create user for testing. +The `@runTestsInSeparateProcesses` and `@preserveGlobalState disabled` +annotation needed by the PHPUnit testing framework for fire up a mink browser +environment. + +## Step definitions +As mentioned above, the FeatureContext plugin is replacing the FeatureContext +class. That class will keep all the step definition. + +The default step definitions defined in a trait. In this way other modules could +provide more step definition and your FeatureContext could leverage them. ## Cucumber files -The cucumber files should be located at MODULE/src/Features/*.feature +By default all the feature files will be located at MODULE/src/Features. In the +future, you could specify other folder location in the plugin definition. + +## Running the tests +There are two ways to run the tests. One way is using the UI under +`admin/config/development/behat` and you can check which files you want to run. +This isn't a good practice since it's not running in batch operation. + +The best way is to use drush: `drush bin/behat PROVIDER URL`. + +The `PROVIDER` is the ID for the FeatureContext plugin. In our case is `behat`. + +The `URL` is the URL of your Drupal 8 installation. + +For example: `drush bin/behat behat http://localhost/drupal8` + +Running specific features could be done with the feature option: +`drush bin/behat behat http://localhost/drupal8 --features=login` -## Patch -You'll need to patch Drupal core with the latest [patch](https://www.drupal.org/node/2232861) +This will run only the login.feature file defined by the behat module. diff --git a/behat.links.menu.yml b/behat.links.menu.yml new file mode 100644 index 0000000..364dc2d --- /dev/null +++ b/behat.links.menu.yml @@ -0,0 +1,6 @@ +behat.test_form: + title: Behat + description: 'Run behat features files.' + route_name: behat.test_form + parent: system.admin_config_development + weight: -6 diff --git a/behat.permissions.yml b/behat.permissions.yml new file mode 100644 index 0000000..e1c224c --- /dev/null +++ b/behat.permissions.yml @@ -0,0 +1,3 @@ +run behat tests: + title: 'Run behat tests' + restrict access: true diff --git a/behat.routing.yml b/behat.routing.yml index 5825365..3090b86 100644 --- a/behat.routing.yml +++ b/behat.routing.yml @@ -6,4 +6,20 @@ behat.state_system_page: options: _admin_route: TRUE requirements: - _permission: 'access devel information' \ No newline at end of file + _permission: 'run behat tests' + +behat.test_form: + path: '/admin/config/development/behat' + defaults: + _form: '\Drupal\behat\Form\BehatTestForm' + _title: 'Behat' + requirements: + _permission: 'run behat tests' + +behat.result_form: + path: '/admin/config/development/behat/results/{test_id}' + defaults: + _form: 'Drupal\behat\Form\BehatResultsForm' + _title: 'Test result' + requirements: + _permission: 'administer unit tests' diff --git a/behat.services.yml b/behat.services.yml index a716192..81c7667 100644 --- a/behat.services.yml +++ b/behat.services.yml @@ -1,4 +1,4 @@ services: - plugin.manager.behat.step: + plugin.manager.behat.FeatureContext: class: Drupal\behat\BehatPluginManager parent: default_plugin_manager \ No newline at end of file diff --git a/composer.json b/composer.json index 2291ff0..b9c166e 100644 --- a/composer.json +++ b/composer.json @@ -1,8 +1,9 @@ { "require": { - "behat/gherkin": "~4.3" + "behat/gherkin": "~4.3", + "symfony/filesystem": "2.6" }, "config": { "vendor-dir": "src/Behat" } -} \ No newline at end of file +} diff --git a/composer.phar b/composer.phar new file mode 100755 index 0000000..8ddf47d Binary files /dev/null and b/composer.phar differ diff --git a/drush/behat.drush.inc b/drush/behat.drush.inc new file mode 100644 index 0000000..a9edead --- /dev/null +++ b/drush/behat.drush.inc @@ -0,0 +1,81 @@ + dt('Run behat scenarios.'), + 'examples' => [ + 'drush bin/behat behat http://localhost/drupal8' => dt('Run all the features files provided by the behat module.'), + 'drush bin/behat behat http://localhost/drupal8 --features=login,comment' => dt('Run selected features files provided by the behat module.'), + ], + 'arguments' => [ + 'provider' => dt('The provider ID as defined in the plugin annotation.'), + 'url' => dt('The URL for the Drupal installation.'), + ], + 'options' => [ + 'features' => dt('List of features in the provider.'), + ], + 'aliases' => ['bin/behat'], + ]; + + return $items; +} + +/** + * Running the tests using drush. + * + * @param $provider + * The name of the provider as defined in the FeaturesContext plugin + * definition. + * + * @throws \Drupal\behat\Exception\BehatException + */ +function drush_behat_run_tests($provider, $url) { + if (!$provider_info = Behat::getFeatureContexts($provider)) { + throw new Exception\BehatException(dt('The provider @provider was not found', ['@provider' => $provider])); + } + + if (!$url) { + throw new Exception\BehatException(dt('You must provide the url to the current Drupal installation.')); + } + + $class = $provider_info['class']; + + $features = $providers = []; + + // Store the feature context and the defining providers. + // todo: Consider themes and features files location defined by provider. + $providers[$class] = DRUPAL_ROOT . '/' . drupal_get_path('module', $provider) . '/src/Features/'; + + $components = BehatDrushHelper::FeaturesForProvider($provider); + + foreach ($components as $component) { + $explode = explode('/', $component); + $features[$class][] = end($explode); + } + + // todo: populate the values. + BehatDrushHelper::SetEnvInformation('FEATURES_PROVIDERS', serialize($providers)); + BehatDrushHelper::SetEnvInformation('FEATURES_RUN', serialize($features)); + BehatDrushHelper::SetEnvInformation('SIMPLETEST_BASE_URL', $url); + $tests_list['phpunit'][] = $class; + + // Run tests. + $test_id = Behat::runTests($tests_list, 'drupal'); + + BehatDrushHelper::DisplaySearchResults($test_id); +} diff --git a/src/Annotation/Step.php b/src/Annotation/FeatureContext.php similarity index 63% rename from src/Annotation/Step.php rename to src/Annotation/FeatureContext.php index 1d25b38..afcaeaa 100644 --- a/src/Annotation/Step.php +++ b/src/Annotation/FeatureContext.php @@ -9,17 +9,17 @@ use Drupal\Component\Annotation\Plugin; /** - * Define a step definition annotation. + * Define a feature context plugin. * * @Annotation */ -class Step extends Plugin { +class FeatureContext extends Plugin { /** * @var String * - * The step. + * The directory. */ - public $step; + public $directory; -} \ No newline at end of file +} diff --git a/src/Behat.php b/src/Behat.php index 49f4261..460426b 100644 --- a/src/Behat.php +++ b/src/Behat.php @@ -9,6 +9,28 @@ class Behat { + public static function getFeatureContexts($provider = NULL) { + $providers = \Drupal::service('plugin.manager.behat.FeatureContext'); + return $provider ? $providers->getDefinition($provider) : $providers->getDefinitions(); + } + + /** + * Return list of all the feature files of a module. + * + * @param $name + * The name of the component. + * @param $type + * The type of the component: module or theme. Default to module. + * @param $dir + * The directory. Default to src/features. + * + * @return array + * Array of features name. + */ + public static function getComponentFeatures($name, $type = 'module', $dir = 'src/Features') { + return glob(drupal_get_path($type, $name) . '/' . $dir . '/*.feature'); + } + /** * Invoking a step. * @@ -20,25 +42,10 @@ class Behat { * @throws BehatStepException * @return null|array */ - public static function Step(BehatTestsAbstract $behat, $step_definition) { - $steps = \Drupal::service('plugin.manager.behat.step')->getDefinitions(); - - foreach ($steps as $step) { - if ($results = self::stepDefinitionMatch($step['id'], $step_definition)) { - // Get the step instance. - $object = \Drupal::service('plugin.manager.behat.step')->createInstance($results['step']); - - // Reflect the instance. - $object_reflection = new \ReflectionClass($object); - $reflection = new \ReflectionClass($object_reflection->getName()); - - // Invoke the - $reflection->getMethod('step')->invokeArgs($object, array($behat) + $results['arguments']); - return TRUE; - } - } + public static function FeatureContext(BehatTestsAbstract $behat, $step_definition) { + $featureContext = \Drupal::service('plugin.manager.behat.FeatureContext')->getDefinitions(); - throw new BehatStepException($step_definition); + return $featureContext; } /** @@ -51,7 +58,7 @@ public static function Step(BehatTestsAbstract $behat, $step_definition) { * @return array|bool */ static public function stepDefinitionMatch($step, $step_definition) { - if (!preg_match('/' . $step . '/', $step_definition, $matches)) { + if (!preg_match($step, $step_definition, $matches)) { return FALSE; } @@ -88,7 +95,7 @@ static public function getParser() { // Allow other module to alter the parser key words. \Drupal::moduleHandler()->alter('behat_parser_words', $keywords); - $lexer = new Lexer($keywords); + $lexer = new Lexer($keywords); return new Parser($lexer); } @@ -97,22 +104,55 @@ static public function getParser() { * This is a dummy method for tests of the behat module. */ public static function content() { - $parser = self::getParser(); - - foreach (glob(drupal_get_path('module', 'behat') . '/src/features/*.feature') as $feature) { - $test = file_get_contents($feature); - $scenarios = $parser->parse($test)->getScenarios(); - - foreach ($scenarios as $scenario) { - foreach ($scenario->getSteps() as $step) { -// dpm($step); - } - } - } - $element = array( '#markup' => 'Hello world!', ); return $element; } -} \ No newline at end of file + + /** + * Find the the step definition from the annotation. + * + * @param $syntax + * The annotation of the method. + * + * @return string + * The step definition. + */ + public static function getBehatStepDefinition($syntax) { + + if (!$start = strpos($syntax, '@Given ')) { + return; + } + + $explode = explode("\n", substr($syntax, $start + strlen('@Given '))); + return $explode[0]; + } + + /** + * Run a list of tests. The function simpletest_run_tests() run the tests but + * not passing the test id variable through the environment variable. + * + * Since we need to run only PHPUnit tests we can set the test ID to the + * environment variable and run the behat tests. + * + * @see simpletest_run_tests(). + */ + public static function runTests($test_list) { + $test_id = db_insert('simpletest_test_id') + ->useDefaults(array('test_id')) + ->execute(); + + if (!empty($test_list['phpunit'])) { + putenv('TESTID=' . $test_id); + $phpunit_results = simpletest_run_phpunit_tests($test_id, $test_list['phpunit']); + simpletest_process_phpunit_results($phpunit_results); + } + + // Early return if there are no further tests to run. + if (empty($test_list['simpletest'])) { + return $test_id; + } + } + +} diff --git a/src/BehatBase.php b/src/BehatBase.php deleted file mode 100644 index a673df0..0000000 --- a/src/BehatBase.php +++ /dev/null @@ -1,47 +0,0 @@ -Behat = $Behat; - } - - /** - * Invoke a step. - * - * @param $step - * The step you need to invoke i.e: "I visit 'user'" - * @param $placeholders - * Optional. Placeholder for elements from the step definition. - * - * @return $this - * The current object. - * . - * @throws BehatFailedStep - */ - public function executeStep($step, $placeholders = []) { - try { - Behat::Step($this->Behat, format_string($step, $placeholders)); - } - catch (\Exception $e) { - throw new BehatFailedStep($e->getMessage()); - } - - return $this; - } -} \ No newline at end of file diff --git a/src/BehatDrushHelper.php b/src/BehatDrushHelper.php new file mode 100644 index 0000000..cefe319 --- /dev/null +++ b/src/BehatDrushHelper.php @@ -0,0 +1,124 @@ + $component, + '@provider' => $provider, + ]; + drush_log(dt('@feature was not found in @provider features folders.', $params), 'warning'); + continue; + } + + $component = $temp_conf; + } + + return $components; + } + + /** + * Set environment variables for the tests. + * + * @param $name + * The variable name. + * @param $value + * The value. + */ + public static function SetEnvInformation($name, $value) { + putenv($name . '=' . $value); + } + + /** + * Display a cool log with colors and indentation. + * + * @param $text + * The log text. + * @param $color + * The color of the log: blue, green, red or yellow. Default to green. + * @param int $indent + * Number of indentation for the text. + */ + public static function coolLog($text, $color = 'green', $indent = 0) { + $colors = [ + 'blue' => 94, + 'green' => 92, + 'red' => 91, + 'yellow' => 93, + 'white' => 37, + ]; + + $string = ''; + $string .= str_repeat(" ", $indent); + $string .= "\033[{$colors[$color]}m{$text}\033[0m\n"; + + echo $string; + } + + /** + * Display the search results. + * + * @param $test_id + * The test ID. + */ + public static function DisplaySearchResults($test_id) { + $yml_path = drupal_get_path('module', 'behat') . '/results/behat-' . $test_id . '.yml'; + + $parser = new Parser(); + $logs = $parser->parse(file_get_contents($yml_path)); + + foreach ($logs as $feature => $steps) { + BehatDrushHelper::coolLog($feature); + + foreach ($steps as $step) { + if ($step['status'] == 'pass') { + BehatDrushHelper::coolLog($step['step'], 'green', 1); + } + else { + $message = format_string('The tests has failed due to: !error',['!error' => $step['step']]); + BehatDrushHelper::coolLog($message, 'red', 1); + exit(1); + } + } + + echo "\n"; + } + + $file = new FileSystem(); + $file->remove($yml_path); + } +} diff --git a/src/BehatPluginManager.php b/src/BehatPluginManager.php index 7285f6a..9e2806b 100644 --- a/src/BehatPluginManager.php +++ b/src/BehatPluginManager.php @@ -27,9 +27,9 @@ class BehatPluginManager extends DefaultPluginManager { * The module handler to invoke the alter hook with. */ public function __construct(\Traversable $namespaces, CacheBackendInterface $cache_backend, ModuleHandlerInterface $module_handler) { - parent::__construct('Plugin/Step', $namespaces, $module_handler, NULL, 'Drupal\behat\Annotation\Step'); - $this->alterInfo('behat_step_alter'); - $this->setCacheBackend($cache_backend, 'behat_steps'); + parent::__construct('Plugin/FeatureContext', $namespaces, $module_handler, NULL, 'Drupal\behat\Annotation\FeatureContext'); + $this->alterInfo('behat_feature_context_alter'); + $this->setCacheBackend($cache_backend, 'behat_feature_context'); } -} \ No newline at end of file +} diff --git a/src/BehatStepAbstract.php b/src/BehatStepAbstract.php deleted file mode 100644 index 33c02fc..0000000 --- a/src/BehatStepAbstract.php +++ /dev/null @@ -1,33 +0,0 @@ - $object_reflection->getName()))); - } - } - -} \ No newline at end of file diff --git a/src/BehatTestsAbstract.php b/src/BehatTestsAbstract.php index 159fde2..20b274c 100644 --- a/src/BehatTestsAbstract.php +++ b/src/BehatTestsAbstract.php @@ -5,8 +5,14 @@ */ namespace Drupal\behat; +use Behat\Gherkin\Keywords\ArrayKeywords; +use Behat\Gherkin\Lexer; use Behat\Gherkin\Node\ScenarioInterface; +use Drupal\behat\Exception\BehatStepException; use Drupal\simpletest\BrowserTestBase; +use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\Yaml\Dumper; +use Symfony\Component\Yaml\Parser; /** * Simple login test. @@ -35,103 +41,109 @@ class BehatTestsAbstract extends BrowserTestBase { /** * @var array * - * Metadata info. + * Holds placeholders for the scenarios. */ - protected $metadata = []; + protected $placeholders = []; /** - * @var array + * @var \Symfony\Component\Filesystem\Filesystem * - * Holds placeholders for the scenarios. + * Symfony file system object. */ - protected $placeholders = []; + protected $fileSystem; /** - * @var + * @var String * - * Holds the tag of the running tests. + * The yml file path. */ - protected $tag; + protected $ymlPath; /** + * Get the yml file content. + * * @return mixed + * The yml content. */ - public function getTag() { - return $this->tag; + public function getYmlFileContent() { + $parser = new Parser(); + return $parser->parse(file_get_contents($this->ymlPath)); } /** - * @param mixed $tag + * Write the content to the file path. * - * @return BehatTestsAbstract + * @param $content */ - public function setTag($tag) { - $this->tag = $tag; - return $this; + public function writeYmlFile($content) { + $dumper = new Dumper(); + file_put_contents($this->ymlPath, $dumper->dump($content)); } /** - * @param $key - * @param $value + * Before each scenario logout the user. * - * @return BehatTestsAbstract - */ - public function setMetadata($key, $value) { - $this->metadata[$key] = $value; - return $this; - } - /** - * @return array + * @param $scenarioInterface + * The scenario object. */ - public function getMetadata() { - return $this->metadata; - } + public function beforeScenario(ScenarioInterface $scenarioInterface = NULL) { + // todo: re-think if this is needed. + // $this->prepareEnvironment(); + // $this->installDrupal(); + // $this->initMink(); - /** - * @return array - */ - public function getEdit() { - return $this->edit; + $this->drupalGet('user/logout'); } /** - * @param $key - * @param $value + * Get the test ID. * - * @return BehatTestsAbstract + * @return integer + * The test ID. */ - public function setEdit($key, $value) { - $this->edit[$key] = $value; - return $this; + protected function getTestID() { + return getenv('TESTID'); } /** - * @param null $key + * Get the features we need to run for each provider. + * + * @param $name + * The name of the class namespace. i.e: + * Drupal\behat\Plugin\FeatureContext\FeatureContextBase + * * @return array + * An array of features files we need to run during the tests. */ - public function getPlaceholders($key = NULL) { - return $key ? $this->placeholders[$key] : $this->placeholders; - } + protected function getFeaturesSettings($name = NULL) { + $features = unserialize(getenv('FEATURES_RUN')); - /** - * @param $key - * @param $value - * - * @return BehatTestsAbstract - */ - public function setPlaceholder($key, $value) { - $this->placeholders[$key] = $value; - return $this; + if ($name && !empty($features[$name])) { + return $features[$name]; + } + + return $features; } /** - * Before each scenario logout the user. + * Get the path for the FeatureContext plugin path. * - * @param $scenarioInterface - * The scenario object. + * @param $name + * The name of the class namespace. i.e: + * Drupal\behat\Plugin\FeatureContext\FeatureContextBase + * + * @return Array|String + * Array or a single path of FeatureContext plugin and their features files + * path. */ - public function beforeScenario(ScenarioInterface $scenarioInterface = NULL) { - $this->drupalGet('user/logout'); + protected function getProvidersPath($name = NULL) { + $providers = unserialize(getenv('FEATURES_PROVIDERS')); + + if ($name && !empty($providers[$name])) { + return $providers[$name]; + } + + return $providers; } /** @@ -140,49 +152,50 @@ public function beforeScenario(ScenarioInterface $scenarioInterface = NULL) { * @param $scenarioInterface * The scenario object. */ - public function afterScenario(ScenarioInterface $scenarioInterface = NULL) {} + public function afterScenario(ScenarioInterface $scenarioInterface = NULL) { + // todo: re-think if this needed. + // $this->tearDown(); + } /** - * Execute a scenario from a feature file. + * Execute a feature file. * - * @param $scenario - * The name of the scenario file. - * @param $component - * Name of the module/theme. - * @param string $type - * The type of the component: module or theme. Default is module. + * @param $path + * The path for the feature file. * * @throws \Exception */ - public function executeScenario($scenario, $component, $type = 'module') { - // Get the path of the file. - $path = DRUPAL_ROOT . '/' . drupal_get_path($type, $component) . '/src/Features/' . $scenario . '.feature'; - - if (!$path) { + public function executeFeature($path) { + if (!file_exists($path)) { throw new \Exception('The scenario is missing from the path ' . $path); } $test = file_get_contents($path); - // Initialize Behat module step manager. - $StepManager = new BehatBase($this); - // Get the parser of the gherkin files. $parser = Behat::getParser(); $scenarios = $parser->parse($test)->getScenarios(); foreach ($scenarios as $scenario) { - if ($this->getTag() && !in_array($this->getTag(), $scenario->getTags())) { - // Run tests with specific tags. - continue; - } - $this->beforeScenario($scenario); foreach ($scenario->getSteps() as $step) { + try { + $this->executeStep(format_string($step->getText(), $this->placeholders)); - // Invoke the steps. - $StepManager->executeStep($step->getText(), $this->getPlaceholders()); + // Log the step the file. + $this->addLine($scenario->getTitle(), [ + 'step' => $step->getText(), + 'status' => 'pass', + ]); + } + catch (\Exception $e) { + $this->addLine($scenario->getTitle(), [ + 'step' => $step->getText() . "
" . $e->getMessage(), + 'status' => 'fail', + ]); + throw new \Exception($e->getMessage()); + } } $this->afterScenario($scenario); @@ -190,24 +203,76 @@ public function executeScenario($scenario, $component, $type = 'module') { } /** - * Visiting a Drupal page. + * Concatenate values to the yml file. * - * @param $path - * The internal path. + * @param $key + * The identifier of the rows. + * @param $value + * Value to concatenate. */ - public function visit($path) { - $this->drupalGet($path); - $this->assertSession()->statusCodeEquals(200); + public function addLine($key, $value) { + $content = $this->getYmlFileContent(); + $content[$key][] = $value; + $this->writeYmlFile($content); } /** - * Sending the form. + * This method will run all the tests in the current request. + */ + public function testRunTests() { + $this->fileSystem = new FileSystem(); + + $testid = $this->getTestID(); + + // Create the folder of the behat in case it doesn't exists. When displaying + // the results we will remove the file for the test. + $behat_path = drupal_get_path('module', 'behat') . '/results'; + $this->ymlPath = $behat_path . '/behat-' . $testid . '.yml'; + + if (!$this->fileSystem->exists($behat_path)) { + $this->fileSystem->mkdir($behat_path); + } + + if (!$this->fileSystem->exists($this->ymlPath)) { + $this->fileSystem->touch($this->ymlPath); + } + + $reflection = new \ReflectionClass($this); + $name = $reflection->getName(); + + // Get the base path of the features files. + $base_path = $this->getProvidersPath($name); + + foreach ($this->getFeaturesSettings($name) as $feature) { + $this->executeFeature($base_path . $feature); + } + } + + /** + * Find in the current instance a method which match the step definition. + * + * @param $step_definition + * The step definition. * - * @param $element - * The submit button element. + * @throws BehatStepException */ - public function sendForm($element) { - $this->submitForm($this->edit, $element); + protected function executeStep($step_definition) { + $reflection = new \ReflectionObject($this); + foreach ($reflection->getMethods() as $method) { + + if (!$step = Behat::getBehatStepDefinition($method->getDocComment())) { + continue; + } + + if ($results = Behat::stepDefinitionMatch($step, $step_definition)) { + // Reflect the instance. + $object_reflection = new \ReflectionClass($this); + $reflection = new \ReflectionClass($object_reflection->getName()); + + // Invoke the method. + $reflection->getMethod($method->getName())->invokeArgs($this, $results['arguments']); + } + } } } diff --git a/src/Exception/BehatFailedStep.php b/src/Exception/BehatFailedStep.php index eec3caf..6d2614e 100644 --- a/src/Exception/BehatFailedStep.php +++ b/src/Exception/BehatFailedStep.php @@ -5,7 +5,6 @@ */ namespace Drupal\behat\Exception; - class BehatFailedStep extends \Exception {} { -} \ No newline at end of file +} diff --git a/src/Features/comment_crud.feature b/src/Features/comment_crud.feature index e69de29..ae80fad 100644 --- a/src/Features/comment_crud.feature +++ b/src/Features/comment_crud.feature @@ -0,0 +1,4 @@ +Feature: Comment crud. + + Scenario: Testing the login form. + Given I visit "user" diff --git a/src/Features/login.feature b/src/Features/login.feature index 7aceaeb..96d111d 100644 --- a/src/Features/login.feature +++ b/src/Features/login.feature @@ -1,17 +1,15 @@ Feature: Login testing. - @login-success Scenario: Testing the login form. - Given I visit 'user' - And I fill in 'Username' with '@user-name' - And I fill in 'Password' with '@user-pass' - When I press 'Log in' - Then I should see '@user-name' + Given I visit "user" + And I fill in "Username" with "@user-name" + And I fill in "Password" with "@user-pass" + When I press "Log in" + Then I should see "@user-name" - @login-failed - Scenario: Testing the user can't login with bas credentials. - Given I visit 'user' - And I fill in 'Username' with 'foo' - And I fill in 'Password' with 'bar' - When I press 'Log in' - Then I should see 'Sorry, unrecognized username or password.' \ No newline at end of file + Scenario: Testing the user can"t login with bas credentials. + Given I visit "user" + And I fill in "Username" with "foo" + And I fill in "Password" with "bar" + When I press "Log in" + Then I should see "Sorry, unrecognized username or password." diff --git a/src/Features/node_crud.feature b/src/Features/node_crud._feature similarity index 100% rename from src/Features/node_crud.feature rename to src/Features/node_crud._feature diff --git a/src/Features/taxonomy_terms_crud.feature b/src/Features/taxonomy_terms_crud._feature similarity index 100% rename from src/Features/taxonomy_terms_crud.feature rename to src/Features/taxonomy_terms_crud._feature diff --git a/src/FeaturesTraits/BasicTrait.php b/src/FeaturesTraits/BasicTrait.php new file mode 100644 index 0000000..d6b21c0 --- /dev/null +++ b/src/FeaturesTraits/BasicTrait.php @@ -0,0 +1,45 @@ +assertSession()->fieldExists($name); + $this->edit[$name] = $value; + } + + /** + * @Given /^I press "([^"]*)"$/ + */ + public function iPress($element) { + $button = $this->assertSession()->buttonExists($element); + + if ($button->getAttribute('type') == 'submit') { + // This is a submit element. Call the submit form method. + $this->submitForm($this->edit, $element); + } + else { + // Normal button. Press it. + $button->press(); + } + } + + /** + * @Given /^I should see "([^"]*)"$/ + */ + public function iShouldSee($text) { + $this->assertSession()->pageTextContains($text); + } + + /** + * @Given /^I visit "([^"]*)"$/ + */ + public function iVisit($url) { + $this->drupalGet($url); + } + +} diff --git a/src/Form/BehatResultsForm.php b/src/Form/BehatResultsForm.php new file mode 100644 index 0000000..e9b6d7d --- /dev/null +++ b/src/Form/BehatResultsForm.php @@ -0,0 +1,263 @@ +t('No test results to display.'), 'error'); + return new RedirectResponse($this->url('behat.test_form', array(), array('absolute' => TRUE))); + } + + // Load all classes and include CSS. + $form['#attached']['library'][] = 'simpletest/drupal.simpletest'; + + // Add the results form. + $form['test_id'] = $test_id; + $filter = static::addResultForm($form, $results, $this->getStringTranslation()); + + // Actions. + $form['#action'] = $this->url('behat.result_form', array('test_id' => 're-run')); + $form['action'] = array( + '#type' => 'fieldset', + '#title' => $this->t('Actions'), + '#attributes' => array('class' => array('container-inline')), + '#weight' => -11, + ); + + $form['action']['filter'] = array( + '#type' => 'select', + '#title' => 'Filter', + '#options' => array( + 'all' => $this->t('All (@count)', array('@count' => count($filter['pass']) + count($filter['fail']))), + 'pass' => $this->t('Pass (@count)', array('@count' => count($filter['pass']))), + 'fail' => $this->t('Fail (@count)', array('@count' => count($filter['fail']))), + ), + ); + $form['action']['filter']['#default_value'] = ($filter['fail'] ? 'fail' : 'all'); + + // Categorized test classes for to be used with selected filter value. + $form['action']['filter_pass'] = array( + '#type' => 'hidden', + '#default_value' => implode(',', $filter['pass']), + ); + $form['action']['filter_fail'] = array( + '#type' => 'hidden', + '#default_value' => implode(',', $filter['fail']), + ); + + $form['action']['op'] = array( + '#type' => 'submit', + '#value' => $this->t('Run tests'), + ); + + $form['action']['return'] = array( + '#type' => 'link', + '#title' => $this->t('Return to list'), + '#url' => Url::fromRoute('behat.test_form'), + ); + + if (FALSE) { + $fileSystem = new Filesystem(); + $fileSystem->remove([drupal_get_path('module', 'behat') . '/results/behat-' . $test_id . '.yml']); + } + + return $form; + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state) { + $pass = $form_state->getValue('filter_pass') ? explode(',', $form_state->getValue('filter_pass')) : array(); + $fail = $form_state->getValue('filter_fail') ? explode(',', $form_state->getValue('filter_fail')) : array(); + + if ($form_state->getValue('filter') == 'all') { + $classes = array_merge($pass, $fail); + } + elseif ($form_state->getValue('filter') == 'pass') { + $classes = $pass; + } + else { + $classes = $fail; + } + + if (!$classes) { + $form_state->setRedirect('behat.test_form'); + return; + } + + $form_execute = array(); + $form_state_execute = new FormState(); + foreach ($classes as $class) { + $form_state_execute->setValue(['tests', $class], $class); + } + + // Submit the simpletest test form to rerun the tests. + // Under normal circumstances, a form object's submitForm() should never be + // called directly, FormBuilder::submitForm() should be called instead. + // However, it calls $form_state->setProgrammed(), which disables the Batch API. + $simpletest_test_form = new BehatTestForm(); + $simpletest_test_form->buildForm($form_execute, $form_state_execute); + $simpletest_test_form->submitForm($form_execute, $form_state_execute); + if ($redirect = $form_state_execute->getRedirect()) { + $form_state->setRedirectUrl($redirect); + } + } + + /** + * Adds the result form to a $form. + * + * This is a static method so that run-tests.sh can use it to generate a + * results page completely external to Drupal. This is why the UI strings are + * not wrapped in t(). + * + * @param array $form + * The form to attach the results to. + * @param array $test_results + * The simpletest results. + * + * @return array + * A list of tests the passed and failed. The array has two keys, 'pass' and + * 'fail'. Each contains a list of test classes. + * + * @see simpletest_script_open_browser() + * @see run-tests.sh + */ + public static function addResultForm(array &$form, array $results) { + $id = $form['test_id']; + + // Transform the test results to be grouped by test class. + $test_results = array(); + foreach ($results as $result) { + if (!isset($test_results[$result->test_class])) { + $test_results[$result->test_class] = array(); + } + $test_results[$result->test_class][] = $result; + } + + $image_status_map = static::buildStatusImageMap(); + + // Keep track of which test cases passed or failed. + $filter = array( + 'pass' => array(), + 'fail' => array(), + ); + + // Summary result widget. + $form['result'] = array( + '#type' => 'fieldset', + '#title' => 'Results', + // Because this is used in a theme-less situation need to provide a + // default. + '#attributes' => array(), + ); + $form['result']['summary'] = $summary = array( + '#theme' => 'simpletest_result_summary', + '#pass' => 0, + '#fail' => 0, + '#exception' => 0, + '#debug' => 0, + ); + + \Drupal::service('test_discovery')->registerTestNamespaces(); + + $yml_path = drupal_get_path('module', 'behat') . '/results/behat-' . $id . '.yml'; + + $parser = new Parser(); + $logs = $parser->parse(file_get_contents($yml_path)); + + // Cycle through each test group. + $header = array( + 'Message', + array('colspan' => 2, 'data' => 'Status') + ); + $form['result']['results'] = array(); + foreach ($logs as $scenario => $assertions) { + // Create group details with summary information. + $form['result']['results'][$scenario] = array( + '#type' => 'details', + '#title' => $scenario, + '#open' => TRUE, + '#description' => 'voo', + ); + $form['result']['results'][$scenario]['summary'] = $summary; + $group_summary =& $form['result']['results'][$scenario]['summary']; + + // Create table of assertions for the group. + $rows = array(); + foreach ($assertions as $assertion) { + $row = array(); + // Assertion messages are in code, so we assume they are safe. + $row[] = SafeMarkup::set($assertion['step']); + $row[] = $image_status_map[$assertion['status']]; + + $class = 'simpletest-' . $assertion['status']; + if ($assertion->message_group == 'Debug') { + $class = 'simpletest-debug'; + } + $rows[] = array('data' => $row, 'class' => array($class)); + + $group_summary['#' . $assertion['status']]++; + $form['result']['summary']['#' . $assertion['status']]++; + } + $form['result']['results'][$scenario]['table'] = array( + '#type' => 'table', + '#header' => $header, + '#rows' => $rows, + ); + + // Set summary information. + $group_summary['#ok'] = $group_summary['#fail'] + $group_summary['#exception'] == 0; + $form['result']['results'][$scenario]['#open'] = !$group_summary['#ok']; + + // Store test group (class) as for use in filter. + $filter[$group_summary['#ok'] ? 'pass' : 'fail'][] = $scenario; + } + + // Overall summary status. + $form['result']['summary']['#ok'] = $form['result']['summary']['#fail'] + $form['result']['summary']['#exception'] == 0; + + return $filter; + } + +} diff --git a/src/Form/BehatTestForm.php b/src/Form/BehatTestForm.php new file mode 100644 index 0000000..c2edb09 --- /dev/null +++ b/src/Form/BehatTestForm.php @@ -0,0 +1,227 @@ + 'actions']; + $form['actions']['submit'] = [ + '#type' => 'submit', + '#value' => $this->t('Run tests'), + '#tableselect' => TRUE, + '#button_type' => 'primary', + ]; + + // Do not needlessly re-execute a full test discovery if the user input + // already contains an explicit list of test classes to run. + $user_input = $form_state->getUserInput(); + if (!empty($user_input['tests'])) { + return $form; + } + + // JavaScript-only table filters. + $form['filters'] = [ + '#type' => 'container', + '#attributes' => [ + 'class' => ['table-filter', 'js-show'], + ], + ]; + $form['filters']['text'] = [ + '#type' => 'search', + '#title' => $this->t('Search'), + '#size' => 30, + '#placeholder' => $this->t('Enter test nameā€¦'), + '#attributes' => [ + 'class' => ['table-filter-text'], + 'data-table' => '#simpletest-test-form', + 'autocomplete' => 'off', + 'title' => $this->t('Enter at least 3 characters of the test name or description to filter by.'), + ], + ]; + + $form['tests'] = [ + '#type' => 'table', + '#id' => 'simpletest-form-table', + '#tableselect' => TRUE, + '#header' => [ + ['data' => $this->t('Test'), 'class' => ['simpletest-test-label']], + ['data' => $this->t('File'), 'class' => ['simpletest-test-description']], + ], + '#empty' => $this->t('No tests to display.'), + '#attached' => [ + 'library' => [ + 'simpletest/drupal.simpletest', + ], + ], + ]; + + // Define the images used to expand/collapse the test groups. + $image_collapsed = [ + '#theme' => 'image', + '#uri' => 'core/misc/menu-collapsed.png', + '#width' => '7', + '#height' => '7', + '#alt' => $this->t('Expand'), + '#title' => $this->t('Expand'), + '#suffix' => '(' . $this->t('Expand') . ')', + ]; + $image_extended = [ + '#theme' => 'image', + '#uri' => 'core/misc/menu-expanded.png', + '#width' => '7', + '#height' => '7', + '#alt' => $this->t('Collapse'), + '#title' => $this->t('Collapse'), + '#suffix' => '(' . $this->t('Collapse') . ')', + ]; + $form['tests']['#attached']['drupalSettings']['simpleTest']['images'] = [ + drupal_render($image_collapsed), + drupal_render($image_extended), + ]; + + // Generate the list of tests arranged by group. + $groups = Behat::getFeatureContexts(); + foreach ($groups as $provider => $tests) { + $this->providers[] = $tests['class']; + $form['tests'][$provider] = [ + '#attributes' => ['class' => ['simpletest-group']], + ]; + + // Make the class name safe for output on the page by replacing all + // non-word/decimal characters with a dash (-). + $group_class = 'module-' . strtolower(trim(preg_replace("/[^\w\d]/", "-", $provider))); + + // Override tableselect column with custom selector for this group. + // This group-select-all checkbox is injected via JavaScript. + $form['tests'][$provider]['select'] = [ + '#wrapper_attributes' => [ + 'id' => $group_class, + 'class' => ['simpletest-group-select-all'], + ], + ]; + $form['tests'][$provider]['title'] = [ + // Expand/collapse image. + '#prefix' => '
', + '#markup' => '', + '#wrapper_attributes' => [ + 'class' => ['simpletest-group-label'], + ], + ]; + $form['tests'][$provider]['description'] = [ + '#markup' => ' ', + '#wrapper_attributes' => [ + 'class' => ['simpletest-group-description'], + ], + ]; + + // Cycle through each test within the current group. + $features = Behat::getComponentFeatures($tests['provider']); + foreach ($features as $delta => $feature) { + + if (!$parsed = $parser->parse(file_get_contents($feature))) { + continue; + } + + $explode = explode('/', $feature); + $feature_name = end($explode); + + $class = $provider . '-' . $feature_name; + $form['tests'][$class] = [ + '#attributes' => ['class' => [$group_class . '-test', 'js-hide']], + ]; + + $form['tests'][$class]['title'] = [ + '#type' => 'label', + '#title' => SafeMarkup::checkPlain($parsed->getTitle()), + '#wrapper_attributes' => [ + 'class' => ['simpletest-test-label', 'table-filter-text-source'], + ], + ]; + $form['tests'][$class]['description'] = [ + '#prefix' => '
', + '#markup' => SafeMarkup::checkPlain($feature_name), + '#suffix' => '
', + '#wrapper_attributes' => [ + 'class' => ['simpletest-test-description', 'table-filter-text-source'], + ], + ]; + } + } + + return $form; + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state) { + global $base_url; + // Test discovery does not run upon form submission. + simpletest_classloader_register(); + + // see \Drupal\simpletest\Form\SimpletestTestForm::submitForm(). + $user_input = $form_state->getUserInput(); + if ($form_state->isValueEmpty('tests') && !empty($user_input['tests'])) { + $form_state->setValue('tests', $user_input['tests']); + } + + if ($selected_tests = $form_state->getValue('tests')) { + // Build a lists of tests. + $features = $tests_list = $providers = []; + foreach ($user_input['tests'] as $test) { + list($provider, $feature) = explode('-', $test); + $class = Behat::getFeatureContexts($provider)['class']; + + // Save what features files we need to run for each provider. + $features[$class][] = str_replace('.features', '', $feature); + + // Store the feature context and the defining providers. + // todo: Consider themes and features files location defined by provider. + $providers[$class] = DRUPAL_ROOT . '/' . drupal_get_path('module', $provider) . '/src/Features/'; + + // Collect all the classes we need to run. + $tests_list['phpunit'][] = $class; + } + + $tests_list['phpunit'] = array_unique($tests_list['phpunit']); + + // Set the + putenv('SIMPLETEST_BASE_URL=' . $base_url); + putenv('FEATURES_RUN=' . serialize($features)); + putenv('FEATURES_PROVIDERS=' . serialize($providers)); + $test_id = Behat::runTests($tests_list, 'drupal'); + $form_state->setRedirect( + 'behat.result_form', + array('test_id' => $test_id) + ); + } + } + +} diff --git a/src/Plugin/FeatureContext/FeatureContextBase.php b/src/Plugin/FeatureContext/FeatureContextBase.php new file mode 100644 index 0000000..eee0f15 --- /dev/null +++ b/src/Plugin/FeatureContext/FeatureContextBase.php @@ -0,0 +1,37 @@ +drupalCreateUser(); + $this->placeholders = [ + '@user-name' => $account->label(), + '@user-pass' => $account->passRaw, + ]; + } +} diff --git a/src/Plugin/Step/Fill.php b/src/Plugin/Step/Fill.php deleted file mode 100644 index 922b49c..0000000 --- a/src/Plugin/Step/Fill.php +++ /dev/null @@ -1,24 +0,0 @@ -assertSession()->fieldExists($name); - $behat->setEdit($name, $value); - } - -} diff --git a/src/Plugin/Step/Press.php b/src/Plugin/Step/Press.php deleted file mode 100644 index 52ccbea..0000000 --- a/src/Plugin/Step/Press.php +++ /dev/null @@ -1,31 +0,0 @@ -assertSession()->buttonExists($element); - - if ($button->getAttribute('type') == 'submit') { - // This is a submit element. Call the submit form method. - $behat->sendForm($element); - } - else { - // Normal button. Press it. - $button->press(); - } - } - -} diff --git a/src/Plugin/Step/ShouldSee.php b/src/Plugin/Step/ShouldSee.php deleted file mode 100644 index 7190bd9..0000000 --- a/src/Plugin/Step/ShouldSee.php +++ /dev/null @@ -1,22 +0,0 @@ -assertSession()->pageTextContains($text); - } - -} diff --git a/src/Plugin/Step/Visit.php b/src/Plugin/Step/Visit.php deleted file mode 100644 index 3546411..0000000 --- a/src/Plugin/Step/Visit.php +++ /dev/null @@ -1,22 +0,0 @@ -visit($url); - } - -}