diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..083b420 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +# EditorConfig is awesome: http://EditorConfig.org + +# top-most EditorConfig file +root = true + +# Unix-style newlines with a newline ending every file +[*.{php,md}] +indent_size = 2 +indent_style = space +trim_trailing_whitespace = true +end_of_line = lf +insert_final_newline = true \ No newline at end of file diff --git a/LICENSE.md b/LICENSE.md index 199ad7d..99fc9df 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -2,6 +2,7 @@ Apache License ============== Copyright 2016 Eduard Sukharev +Copyright 2018 Tiko Lakin _Version 2.0, January 2004_ _<>_ diff --git a/README.md b/README.md index 9c6256e..589f7eb 100644 --- a/README.md +++ b/README.md @@ -10,44 +10,47 @@ To install using [Composer](https://getcomposer.org/) simply add `"allure-framew ... "require": { ... - "allure-framework/allure-behat": "~1.0.0", + "allure-framework/allure-behat": "~2.0.0", ... }, ... ## Usage -To enable this extension in [Behat](http://behat.org/en/latest/), add it to `extensions` section of your ```behat.yml``` file: - - extensions: - Allure\Behat\AllureFormatterExtension: ~ - -To use Allure formatter, add `allure` to your list of formatters in `name`: +To enable this extension in [Behat](http://behat.org/en/latest/), add it to `extensions` section of your ```behat.yml``` file +To use Allure formatter, add `allure` to your list of formatters in `name` ```yml - - formatter: - name: pretty,allure - parameters: - output: build/allure-report - delete_previous_results: false - ignored_tags: javascript - severity_tag_prefix: 'severity_' - issue_tag_prefix: 'bug_' - test_id_tag_prefix: 'test_case_' - + formatters: + pretty: true + allure: + output_path: %paths.base%/build/allure + extensions: + Allure\Behat\AllureFormatterExtension: + severity_key: "severity:" + ignored_tags: "tag_ignore" + issue_tag_prefix: "JIRA:" + test_id_tag_prefix: "BUG:" ``` + Here: - - `output` - defines the output dir for report XML data - - `delete_previous_results` - defines whether to remove all files in `output` folder before test run + - `output_path` - defines the output dir for report XML data. Default is `./allure-results` - `ignored_tags` - either a comma separated string or valid yaml array of Scenario tags to be ignored in reports - - `severity_tag_prefix` - tag with this prefix will be interpreted (if possible) to define the Scenario severity level + - `severity_key` - tag with this prefix will be interpreted (if possible) to define the Scenario severity level in reports (by default it's `normal`). - `issue_tag_prefix` - tag with this prefix will be interpreted as Issue marker and will generate issue tracking system link for test case (using [**allure.issues.tracker.pattern** setting for allure-cli](https://github.com/allure-framework/allure-core/wiki/Issues)) - `test_id_tag_prefix` - tag with this prefix will be interpreted as Test Case Id marker and will generate TMS link for test case (using [**allure.tests.management.pattern** setting for allure-cli](https://github.com/allure-framework/allure-core/wiki/Test-Case-ID)) + +### Use attachment support +To have attachments in allure report - make sure your behat runs tests with [Mink](https://github.com/minkphp/Mink) + +Allure can handle exception thrown in your Context if that exception is instance of `ArtifactExceptionInterface` +and get screenshots path from it. + + ### How does it work? Behat has the following test structure: @@ -69,8 +72,8 @@ On the other hand, Allure also supports grouping Test Cases by Feature, by Story Behat Allure formatter does the following mapping: * Behat Test Run -> Allure Test Suite -* Behat Scenario (and every single Example in Scenario Outline, too) -> Allure Test Case -* Behat Sentence -> Allure Test Step +* Gherkin Scenario (and every single Example in Scenario Outline, too) -> Allure Test Case +* Gherkin Step -> Allure Test Step Behat Scenarios are annotated with it's feature title and description to be grouped into Allure Feature. @@ -83,5 +86,6 @@ Behat also has tags and they are also can be used in Allure reports: [Test Case Id](https://github.com/allure-framework/allure-core/wiki/Test-Case-ID) for your TMS * In all other cases tag will be parsed as Allure Story annotation -By default, this formatter will use `build/allure-results` folder to put it's XML output to. Each Behat run will empty -that folder. To override this behavior, define `output` and `delete_previous_results` parameters respectively. +### Contribution? +Feel free to open PR with changes but before pls make sure you pass tests +`./vendor/behat/behat/bin/behat` diff --git a/composer.json b/composer.json index f08b623..b45c2c6 100644 --- a/composer.json +++ b/composer.json @@ -1,23 +1,34 @@ { - "name": "allure-framework/allure-behat", - "description": "Behat output formatter for use with Yandex Allure reporting tool", - "keywords": ["BDD", "Behat", "Allure"], - "license": "Apache 2.0", - "authors": [ - { - "name": "Eduard Sukharev", - "email": "sukharev.eh@gmail.com" - } - ], - - "require": { - "php": ">=5.5", - "behat/behat": "~2.4", - "allure-framework/allure-php-api": "~1.1.4" + "name": "allure-framework/allure-behat", + "description": "Behat output formatter for use with Yandex Allure reporting tool", + "keywords": [ + "BDD", + "Behat", + "Allure" + ], + "license": "Apache 2.0", + "authors": [ + { + "name": "Eduard Sukharev", + "email": "sukharev.eh@gmail.com" }, - "autoload": { - "psr-0": { - "": "src/" - } + { + "name": "Tiko Lakin", + "email": "tikolakin@gmail.com" } + ], + "require": { + "php": ">=5.5", + "behat/behat": "^3.3", + "allure-framework/allure-php-api": "~1.1.4" + }, + "require-dev": { + "symfony/process": "~2.5|~3.0|~4.0", + "phpunit/phpunit": "^4.8.36|^6.3" + }, + "autoload": { + "psr-0": { + "": "src/" + } + } } diff --git a/features/allure_formatter.feature b/features/allure_formatter.feature new file mode 100644 index 0000000..152f4bf --- /dev/null +++ b/features/allure_formatter.feature @@ -0,0 +1,98 @@ +Feature: Allure Formatter + In order integrate with Allure test report tool + As a developer + I need to be able to generate a allure-compatible report + + Scenario: Scenario annotation + Given a file named "behat.yml" with: + """ + default: + formatters: + pretty: false + allure: true + extensions: + Allure\Behat\AllureFormatterExtension: + severity_key: "severity:" + ignored_tags: "tag_ignore" + issue_tag_prefix: "JIRA:" + test_id_tag_prefix: "BUG:" + """ + Given a file named "features/bootstrap/FeatureContext.php" with: + """ + + + default + + + features/World.feature:8 + Scenario annotation + + + + scenario has annotation + Given scenario has annotation + + + it passed + When it passed + + + annotation is collected + Then annotation is collected + + + + + + + + """ \ No newline at end of file diff --git a/features/bootstrap/FeatureContext.php b/features/bootstrap/FeatureContext.php new file mode 100644 index 0000000..84d51ee --- /dev/null +++ b/features/bootstrap/FeatureContext.php @@ -0,0 +1,470 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Behat\Behat\Context\Context; +use Behat\Gherkin\Node\PyStringNode; +use PHPUnit\Framework\Assert; +use Symfony\Component\Process\PhpExecutableFinder; +use Symfony\Component\Process\Process; + +/** + * Behat test suite context. + * + * @author Konstantin Kudryashov + */ +class FeatureContext implements Context +{ + /** + * @var string + */ + private $phpBin; + /** + * @var Process + */ + private $process; + /** + * @var string + */ + private $workingDir; + /** + * @var string + */ + private $options = '--format-settings=\'{"timer": false}\' --no-interaction'; + + /** + * Cleans test folders in the temporary directory. + * + * @BeforeSuite + * @AfterSuite + */ + public static function cleanTestFolders() + { + if (is_dir($dir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'behat')) { + self::clearDirectory($dir); + } + } + + /** + * Prepares test folders in the temporary directory. + * + * @BeforeScenario + */ + public function prepareTestFolders() + { + $dir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'behat' . DIRECTORY_SEPARATOR . + md5(microtime() . rand(0, 10000)); + + mkdir($dir . '/features/bootstrap/i18n', 0777, true); + mkdir($dir . '/junit'); + + $phpFinder = new PhpExecutableFinder(); + if (false === $php = $phpFinder->find()) { + throw new \RuntimeException('Unable to find the PHP executable.'); + } + $this->workingDir = $dir; + $this->phpBin = $php; + $this->process = new Process(null); + $this->process->setTimeout(20); + } + + /** + * Creates a file with specified name and context in current workdir. + * + * @Given /^(?:there is )?a file named "([^"]*)" with:$/ + * + * @param string $filename name of the file (relative path) + * @param PyStringNode $content PyString string instance + */ + public function aFileNamedWith($filename, PyStringNode $content) + { + $content = strtr((string) $content, array("'''" => '"""')); + $this->createFile($this->workingDir . '/' . $filename, $content); + } + + /** + * Creates a empty file with specified name in current workdir. + * + * @Given /^(?:there is )?a file named "([^"]*)"$/ + * + * @param string $filename name of the file (relative path) + */ + public function aFileNamed($filename) + { + $this->createFile($this->workingDir . '/' . $filename, ''); + } + + /** + * Creates a noop feature context in current workdir. + * + * @Given /^(?:there is )?a some feature context$/ + */ + public function aNoopFeatureContext() + { + $filename = 'features/bootstrap/FeatureContext.php'; + $content = <<<'EOL' +createFile($this->workingDir . '/' . $filename, $content); + } + + /** + * Creates a noop feature in current workdir. + * + * @Given /^(?:there is )?a some feature scenarios/ + */ + public function aNoopFeature() + { + $filename = 'features/bootstrap/FeatureContext.php'; + $content = <<<'EOL' +Feature: + Scenario: + When this scenario executes +EOL; + $this->createFile($this->workingDir . '/' . $filename, $content); + } + + /** + * Moves user to the specified path. + * + * @Given /^I am in the "([^"]*)" path$/ + * + * @param string $path + */ + public function iAmInThePath($path) + { + $this->moveToNewPath($path); + } + + /** + * Checks whether a file at provided path exists. + * + * @Given /^file "([^"]*)" should exist$/ + * + * @param string $path + */ + public function fileShouldExist($path) + { + $this->findFileByPattern($this->workingDir . DIRECTORY_SEPARATOR . $path); + } + + /** + * Sets specified ENV variable + * + * @When /^"BEHAT_PARAMS" environment variable is set to:$/ + * + * @param PyStringNode $value + */ + public function iSetEnvironmentVariable(PyStringNode $value) + { + $this->process->setEnv(array('BEHAT_PARAMS' => (string) $value)); + } + + /** + * Runs behat command with provided parameters + * + * @When /^I run "behat(?: ((?:\"|[^"])*))?"$/ + * + * @param string $argumentsString + */ + public function iRunBehat($argumentsString = '') + { + $argumentsString = strtr($argumentsString, array('\'' => '"')); + + $this->process->setWorkingDirectory($this->workingDir); + $this->process->setCommandLine( + sprintf( + '%s %s %s %s', + $this->phpBin, + escapeshellarg(BEHAT_BIN_PATH), + $argumentsString, + strtr($this->options, array('\'' => '"', '"' => '\"')) + ) + ); + + // Don't reset the LANG variable on HHVM, because it breaks HHVM itself + if (!defined('HHVM_VERSION')) { + $env = $this->process->getEnv(); + $env['LANG'] = 'en'; // Ensures that the default language is en, whatever the OS locale is. + $this->process->setEnv($env); + } + + $this->process->run(); + } + + /** + * Runs behat command with provided parameters in interactive mode + * + * @When /^I answer "([^"]+)" when running "behat(?: ((?:\"|[^"])*))?"$/ + * + * @param string $answerString + * @param string $argumentsString + */ + public function iRunBehatInteractively($answerString, $argumentsString) + { + $env = $this->process->getEnv(); + $env['SHELL_INTERACTIVE'] = true; + + $this->process->setEnv($env); + $this->process->setInput($answerString); + + $this->options = '--format-settings=\'{"timer": false}\''; + $this->iRunBehat($argumentsString); + } + + /** + * Runs behat command in debug mode + * + * @When /^I run behat in debug mode$/ + */ + public function iRunBehatInDebugMode() + { + $this->options = ''; + $this->iRunBehat('--debug'); + } + + /** + * Checks whether previously ran command passes|fails with provided output. + * + * @Then /^it should (fail|pass) with:$/ + * + * @param string $success "fail" or "pass" + * @param PyStringNode $text PyString text instance + */ + public function itShouldPassWith($success, PyStringNode $text) + { + $this->itShouldFail($success); + $this->theOutputShouldContain($text); + } + + /** + * Checks whether previously runned command passes|failes with no output. + * + * @Then /^it should (fail|pass) with no output$/ + * + * @param string $success "fail" or "pass" + */ + public function itShouldPassWithNoOutput($success) + { + $this->itShouldFail($success); + Assert::assertEmpty($this->getOutput()); + } + + /** + * Checks whether specified file exists and contains specified string. + * + * @Then /^"([^"]*)" file should contain:$/ + * + * @param string $path file path + * @param PyStringNode $text file content + */ + public function fileShouldContain($path, PyStringNode $text) + { + $path = $this->findFileByPattern($this->workingDir . DIRECTORY_SEPARATOR . $path); + + $fileContent = trim(file_get_contents($path)); + // Normalize the line endings in the output + if ("\n" !== PHP_EOL) { + $fileContent = str_replace(PHP_EOL, "\n", $fileContent); + } + + Assert::assertEquals($this->getExpectedOutput($text), $fileContent); + } + + /** + * Checks whether specified content and structure of the xml is correct without worrying about layout. + * + * @Then /^"([^"]*)" file xml should be like:$/ + * + * @param string $path file path + * @param PyStringNode $text file content + */ + public function fileXmlShouldBeLike($path, PyStringNode $text) + { + $path = $this->findFileByPattern($this->workingDir . DIRECTORY_SEPARATOR . $path); + + $fileContent = trim(file_get_contents($path)); + + $fileContent = preg_replace('/start="(\d+)"/', 'start="-IGNORE-VALUE-"', $fileContent); + $fileContent = preg_replace('/stop="(\d+)"/', 'stop="-IGNORE-VALUE-"', $fileContent); + + $dom = new DOMDocument(); + $dom->loadXML($text); + $dom->formatOutput = true; + + Assert::assertEquals(trim($dom->saveXML(null)), $fileContent); + } + + + /** + * Checks whether last command output contains provided string. + * + * @Then the output should contain: + * + * @param PyStringNode $text PyString text instance + */ + public function theOutputShouldContain(PyStringNode $text) + { + Assert::assertContains($this->getExpectedOutput($text), $this->getOutput()); + } + + private function getExpectedOutput(PyStringNode $expectedText) + { + $text = strtr($expectedText, array( + '\'\'\'' => '"""', + '%%TMP_DIR%%' => sys_get_temp_dir() . DIRECTORY_SEPARATOR, + '%%WORKING_DIR%%' => realpath($this->workingDir . DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR, + '%%DS%%' => DIRECTORY_SEPARATOR, + )); + + // windows path fix + if ('/' !== DIRECTORY_SEPARATOR) { + $text = preg_replace_callback( + '/[ "]features\/[^\n "]+/', function ($matches) { + return str_replace('/', DIRECTORY_SEPARATOR, $matches[0]); + }, $text + ); + $text = preg_replace_callback( + '/\features\/[^\<]+/', function ($matches) { + return str_replace('/', DIRECTORY_SEPARATOR, $matches[0]); + }, $text + ); + $text = preg_replace_callback( + '/\+[fd] [^ ]+/', function ($matches) { + return str_replace('/', DIRECTORY_SEPARATOR, $matches[0]); + }, $text + ); + } + + return $text; + } + + /** + * Checks whether previously ran command failed|passed. + * + * @Then /^it should (fail|pass)$/ + * + * @param string $success "fail" or "pass" + */ + public function itShouldFail($success) + { + if ('fail' === $success) { + if (0 === $this->getExitCode()) { + echo 'Actual output:' . PHP_EOL . PHP_EOL . $this->getOutput(); + } + + Assert::assertNotEquals(0, $this->getExitCode()); + } else { + if (0 !== $this->getExitCode()) { + echo 'Actual output:' . PHP_EOL . PHP_EOL . $this->getOutput(); + } + + Assert::assertEquals(0, $this->getExitCode()); + } + } + + /** + * Checks whether the file is valid according to an XML schema. + * + * @Then /^the file "([^"]+)" should be a valid document according to "([^"]+)"$/ + * + * @param string $xmlFile + * @param string $schemaPath relative to features/bootstrap/schema + */ + public function xmlShouldBeValid($xmlFile, $schemaPath) + { + $dom = new DomDocument(); + $dom->load($this->findFileByPattern($this->workingDir . DIRECTORY_SEPARATOR . $xmlFile)); + + $dom->schemaValidate(__DIR__ . '/schema/' . $schemaPath); + } + + private function getExitCode() + { + return $this->process->getExitCode(); + } + + private function getOutput() + { + $output = $this->process->getErrorOutput() . $this->process->getOutput(); + + // Normalize the line endings in the output + if ("\n" !== PHP_EOL) { + $output = str_replace(PHP_EOL, "\n", $output); + } + + // Replace wrong warning message of HHVM + $output = str_replace('Notice: Undefined index: ', 'Notice: Undefined offset: ', $output); + + return trim(preg_replace("/ +$/m", '', $output)); + } + + private function createFile($filename, $content) + { + $path = dirname($filename); + $this->createDirectory($path); + + file_put_contents($filename, $content); + } + + private function createDirectory($path) + { + if (!is_dir($path)) { + mkdir($path, 0777, true); + } + } + + private function moveToNewPath($path) + { + $newWorkingDir = $this->workingDir .'/' . $path; + if (!file_exists($newWorkingDir)) { + mkdir($newWorkingDir, 0777, true); + } + + $this->workingDir = $newWorkingDir; + } + + private static function clearDirectory($path) + { + $files = scandir($path); + array_shift($files); + array_shift($files); + + foreach ($files as $file) { + $file = $path . DIRECTORY_SEPARATOR . $file; + if (is_dir($file)) { + self::clearDirectory($file); + } else { + unlink($file); + } + } + + rmdir($path); + } + + /** + * Looks for a file by it's name or a pattern like "default*.xml" + * + * @param string $path + * @return string + */ + private function findFileByPattern($path) + { + $files = glob($path); + Assert::assertNotFalse($files, "Error occurred when searching for files."); + Assert::assertCount(1, $files, "Couldn't find file at $path"); + return array_shift($files); + } +} diff --git a/src/Allure/Behat/AllureFormatterExtension.php b/src/Allure/Behat/AllureFormatterExtension.php index 9160881..aa17641 100644 --- a/src/Allure/Behat/AllureFormatterExtension.php +++ b/src/Allure/Behat/AllureFormatterExtension.php @@ -1,6 +1,7 @@ setParameter( - 'behat.formatter.classes', - array('allure' => 'Allure\Behat\Formatter\AllureFormatter') - ); - } - - /** - * Setups configuration for current extension. - * - * @param ArrayNodeDefinition $builder - */ - public function getConfig(ArrayNodeDefinition $builder) - { - $builder - ->useAttributeAsKey('name') - ->prototype('variable'); - } - - /** - * Returns compiler passes used by this extension. - * - * @return array - */ - public function getCompilerPasses() - { - return array(); - } -} \ No newline at end of file + + /** + * You can modify the container here before it is dumped to PHP code. + * + * @param ContainerBuilder $container + */ + public function process(ContainerBuilder $container) + { + + } + + /** + * Returns the extension config key. + * + * @return string + */ + public function getConfigKey() + { + return 'allure'; + } + + /** + * Initializes other extensions. + * + * This method is called immediately after all extensions are activated but + * before any extension `configure()` method is called. This allows extensions + * to hook into the configuration of other extensions providing such an + * extension point. + * + * @param ExtensionManager $extensionManager + */ + public function initialize(ExtensionManager $extensionManager) + { + + } + + /** + * Setups configuration for the extension. + * + * @param ArrayNodeDefinition $builder + */ + public function configure(ArrayNodeDefinition $builder) + { + $builder->children()->scalarNode("name")->defaultValue('allure'); + $builder->children()->scalarNode("issue_tag_prefix")->defaultValue(null); + $builder->children()->scalarNode("test_id_tag_prefix")->defaultValue(null); + $builder->children()->scalarNode("ignored_tags")->defaultValue(null); + $builder->children()->scalarNode("severity_key")->defaultValue(null); + } + + /** + * Loads extension services into temporary container. + * + * @param ContainerBuilder $container + * @param array $config + */ + public function load(ContainerBuilder $container, array $config) + { + $definition = new Definition("Allure\\Behat\\Formatter\\AllureFormatter"); + $definition->addArgument($config['name']); + $definition->addArgument($config['issue_tag_prefix']); + $definition->addArgument($config['test_id_tag_prefix']); + $definition->addArgument($config['ignored_tags']); + $definition->addArgument($config['severity_key']); + $definition->addArgument('%paths.base%'); + $presenter = new Reference(ExceptionExtension::PRESENTER_ID); + $definition->addArgument($presenter); + $container->setDefinition("allure.formatter", $definition) + ->addTag("output.formatter"); + } + +} diff --git a/src/Allure/Behat/Exception/ArtifactExceptionInterface.php b/src/Allure/Behat/Exception/ArtifactExceptionInterface.php new file mode 100644 index 0000000..ea5781e --- /dev/null +++ b/src/Allure/Behat/Exception/ArtifactExceptionInterface.php @@ -0,0 +1,46 @@ + - */ -class AllureFormatter implements FormatterInterface +class AllureFormatter implements Formatter { - private $translator; - - private $parameters; - - private $uuid; - - /** - * @var Exception|Throwable - */ - private $exception; - public function __construct() - { - $defaultLanguage = null; - if (($locale = getenv('LANG')) && preg_match('/^([a-z]{2})/', $locale, $matches)) { - $defaultLanguage = $matches[1]; - } - - $this->parameters = new ParameterBag(array( - 'language' => $defaultLanguage, - 'output' => 'build' . DIRECTORY_SEPARATOR . 'allure-results', - 'ignored_tags' => array(), - 'severity_tag_prefix' => 'severity_', - 'issue_tag_prefix' => 'bug_', - 'test_id_tag_prefix' => 'test_', - 'delete_previous_results' => true, - )); + protected $output; + protected $name; + protected $base_path; + protected $timer; + protected $exception; + protected $attachment = []; + protected $uuid; + protected $issueTagPrefix; + protected $testIdTagPrefix; + protected $ignoredTags; + protected $severity_key; + protected $parameters; + protected $printer; + protected $outlineCounter = 0; + + /** @var \Behat\Testwork\Exception\ExceptionPresenter */ + protected $presenter; + + /** @var Allure */ + private $lifecycle; + + private $scopeAnnotation = []; + + use AttachmentSupport; + + public function __construct($name, $issue_tag_prefix, $test_id_tag_prefix, $ignoredTags, $severity_key, $base_path, $presenter) + { + $this->name = $name; + $this->issueTagPrefix = $issue_tag_prefix; + $this->testIdTagPrefix = $test_id_tag_prefix; + $this->ignoredTags = $ignoredTags; + $this->severity_key = $severity_key; + $this->base_path = $base_path; + $this->presenter = $presenter; + $this->timer = new Timer(); + $this->printer = new DummyOutputPrinter(); + $this->parameters = new ParameterBag(); + } + + private function getLifeCycle() + { + if (!isset($this->lifecycle)) { + $this->lifecycle = Allure::lifecycle(); } + return $this->lifecycle; + } + + /** + * Returns an array of event names this subscriber wants to listen to. + * + * The array keys are event names and the value can be: + * + * * The method name to call (priority defaults to 0) + * * An array composed of the method name to call and the priority + * * An array of arrays composed of the method names to call and respective + * priorities, or 0 if unset + * + * For instance: + * + * * array('eventName' => 'methodName') + * * array('eventName' => array('methodName', $priority)) + * * array('eventName' => array(array('methodName1', $priority), + * array('methodName2'))) + * + * @return array The event names to listen to + */ + public static function getSubscribedEvents() + { + return array( + 'tester.exercise_completed.before' => 'onBeforeExerciseCompleted', + 'tester.exercise_completed.after' => 'onAfterExerciseCompleted', + 'tester.suite_tested.before' => 'onBeforeSuiteTested', + 'tester.suite_tested.after' => 'onAfterSuiteTested', + 'tester.feature_tested.before' => 'onBeforeFeatureTested', + 'tester.feature_tested.after' => 'onAfterFeatureTested', + 'tester.scenario_tested.before' => 'onBeforeScenarioTested', + 'tester.scenario_tested.after' => 'onAfterScenarioTested', + 'tester.outline_tested.before' => 'onBeforeOutlineTested', + 'tester.outline_tested.after' => 'onAfterOutlineTested', + 'tester.step_tested.before' => 'onBeforeStepTested', + 'tester.step_tested.after' => 'onAfterStepTested', + ); + } + + /** + * Returns formatter name. + * + * @return string + */ + public function getName() + { + return $this->name; + } + + /** + * Returns formatter description. + * + * @return string + */ + public function getDescription() + { + return "Allure formatter for Behat 3"; + } + + /** + * Returns formatter output printer. + * + * @return OutputPrinter + */ + public function getOutputPrinter() + { + return $this->printer; + } + + /** + * Sets formatter parameter. + * + * @param string $name + * @param mixed $value + */ + public function setParameter($name, $value) + { + $this->parameters->set($name, $value); + } + + /** + * Returns parameter name. + * + * @param string $name + * + * @return mixed + */ + public function getParameter($name) + { + return $this->parameters->get($name); + } + + public function onBeforeExerciseCompleted(BeforeExerciseCompleted $event) + { + + } + + public function onAfterExerciseCompleted(AfterExerciseCompleted $event) + { + + } + + public function onBeforeSuiteTested(BeforeSuiteTested $event) + { + + AnnotationProvider::addIgnoredAnnotations([]); + $this->prepareOutputDirectory( + $this->printer->getOutputPath() + ); + $start_event = new TestSuiteStartedEvent($event->getSuite()->getName()); + + $this->uuid = $start_event->getUuid(); + + $this->getLifeCycle()->fire($start_event); + } + + public function onAfterSuiteTested(AfterSuiteTested $event) + { + AnnotationProvider::registerAnnotationNamespaces(); + $this->getLifeCycle()->fire(new TestSuiteFinishedEvent($this->uuid)); + + } + + public function onBeforeFeatureTested(BeforeFeatureTested $event) + { + + } + + public function onAfterFeatureTested(AfterFeatureTested $event) + { + + } + + public function onBeforeScenarioTested(BeforeScenarioTested $event) + { + /** @var \Behat\Gherkin\Node\ScenarioNode $scenario */ + $scenario = $event->getScenario(); + /** @var \Behat\Gherkin\Node\FeatureNode $feature */ + $feature = $event->getFeature(); + + + $annotations = array_merge( + $this->parseFeatureAnnotations($feature), + $this->parseScenarioAnnotations($scenario) + ); + + $annotationManager = new AnnotationManager($annotations); + $scenarioName = sprintf('%s:%d', $feature->getFile(), $scenario->getLine()); + $scenarioEvent = new TestCaseStartedEvent($this->uuid, $scenarioName); + $annotationManager->updateTestCaseEvent($scenarioEvent); + + $this->getLifeCycle()->fire($scenarioEvent->withTitle($scenario->getTitle())); + + } + + public function onAfterScenarioTested(AfterScenarioTested $event) + { + $this->processScenarioResult($event->getTestResult()); + } + + public function onBeforeOutlineTested(BeforeOutlineTested $event) + { + $examples = $event->getOutline()->getExamples(); - /** - * Set formatter translator. - * - * @param Translator $translator - */ - public function setTranslator(Translator $translator) - { - $this->translator = $translator; + if ($this->outlineCounter >= count($examples)) { + $this->outlineCounter = 0; } - /** - * Checks if current formatter has parameter. - * - * @param string $name - * - * @return Boolean - */ - public function hasParameter($name) - { - return $this->parameters->has($name); + $example = $examples[$this->outlineCounter]; + $feature = $event->getFeature(); + + $scenarioName = sprintf( + '%s:%d', + $feature->getFile(), + $example->getLine() + ); + + $scenarioEvent = new TestCaseStartedEvent($this->uuid, $scenarioName); + $annotations = array_merge( + $this->parseFeatureAnnotations($feature), + $this->parseScenarioAnnotations($example), + $this->parseExampleAnnotations($example->getTokens()) + ); + $this->outlineCounter++; + $annotationManager = new AnnotationManager($annotations); + $annotationManager->updateTestCaseEvent($scenarioEvent); + $this->getLifeCycle()->fire($scenarioEvent->withTitle($example->getOutlineTitle())); + } + + public function onAfterOutlineTested(AfterOutlineTested $event) + { + $this->processScenarioResult($event->getTestResult()); + } + + public function onBeforeStepTested(BeforeStepTested $event) + { + $step = $event->getStep(); + $stepEvent = new StepStartedEvent($step->getText()); + $stepEvent->withTitle(sprintf('%s %s', $step->getType(), $step->getText())); + + $this->getLifeCycle()->fire($stepEvent); + } + + public function onAfterStepTested(AfterStepTested $event) + { + $result = $event->getTestResult(); + + if ($result instanceof ExceptionResult && $result->hasException()) { + $this->exception = $result->getException(); + if ($this->exception instanceof ArtifactExceptionInterface) { + $this->attachment[md5_file($this->exception->getScreenPath())] = $this->exception->getScreenPath(); + $this->attachment[md5_file($this->exception->getHtmlPath())] = $this->exception->getHtmlPath(); + } } - /** - * Sets formatter parameter. - * - * @param string $name - * @param mixed $value - */ - public function setParameter($name, $value) - { - $this->parameters->set($name, $value); + switch ($event->getTestResult()->getResultCode()) { + case StepResult::FAILED: + $this->addFailedStep(); + break; + case StepResult::UNDEFINED: + $this->addFailedStep(); + break; + case StepResult::PENDING: + case StepResult::SKIPPED: + $this->addCancelledStep(); + break; + case StepResult::PASSED: + default: + $this->exception = new \Exception('Error occurred out of test scope.'); } + $this->addFinishedStep(); + } - /** - * Returns parameter name. - * - * @param string $name - * - * @return mixed - */ - public function getParameter($name) - { - return $this->parameters->get($name); + protected function prepareOutputDirectory($outputDirectory) + { + if (!file_exists($outputDirectory)) { + mkdir($outputDirectory, 0755, true); } - /** - * @return array - */ - public static function getSubscribedEvents() - { - $events = array( - 'beforeSuite', - 'afterSuite', - 'beforeScenario', - 'afterScenario', - 'beforeOutlineExample', - 'afterOutlineExample', - 'beforeStep', - 'afterStep', - ); - - return array_combine($events, $events); + if (is_null(Provider::getOutputDirectory())) { + Provider::setOutputDirectory($outputDirectory); } + } - /** - * @param SuiteEvent $suiteEvent - */ - public function beforeSuite(SuiteEvent $suiteEvent) - { - AnnotationProvider::addIgnoredAnnotations(array()); + protected function parseFeatureAnnotations(FeatureNode $featureNode) + { + $this->scopeAnnotation = $featureNode->getTags(); + $description = new Description(); + $description->type = DescriptionType::TEXT; + $description->value = $featureNode->getDescription(); + return [$this->scopeAnnotation, $description]; + } - $this->prepareOutputDirectory( - $this->parameters->get('output'), - $this->parameters->get('delete_previous_results') - ); - $now = new DateTime(); - $event = new TestSuiteStartedEvent(sprintf('TestSuite-%s', $now->format('Y-m-d_His'))); + protected function parseScenarioAnnotations(ScenarioInterface $scenarioNode) + { - $this->uuid = $event->getUuid(); + $annotations = []; - Allure::lifecycle()->fire($event); - } - - public function afterSuite(SuiteEvent $suiteEvent) - { - Allure::lifecycle()->fire(new TestSuiteFinishedEvent($this->uuid)); - } + $story = new Stories(); + $story->stories = []; - /** - * @param ScenarioEvent $scenarioEvent - */ - public function beforeScenario(ScenarioEvent $scenarioEvent) - { - $scenario = $scenarioEvent->getScenario(); - $annotations = array_merge( - $this->parseFeatureAnnotations($scenarioEvent->getScenario()->getFeature()), - $this->parseScenarioAnnotations($scenario) - ); - $annotationManager = new AnnotationManager($annotations); - - $scenarioName = sprintf('%s:%d', $scenario->getFile(), $scenario->getLine()); - $event = new TestCaseStartedEvent($this->uuid, $scenarioName); - $annotationManager->updateTestCaseEvent($event); - - Allure::lifecycle()->fire($event->withTitle($scenario->getTitle())); - } + $issues = new Issues(); + $issues->issueKeys = []; - public function beforeOutlineExample(OutlineExampleEvent $outlineExampleEvent) - { - $scenarioOutline = $outlineExampleEvent->getOutline(); - - $scenarioName = sprintf( - '%s:%d [%d]', - $scenarioOutline->getFile(), - $scenarioOutline->getLine(), - $outlineExampleEvent->getIteration() - ); - $event = new TestCaseStartedEvent($this->uuid, $scenarioName); - - $annotations = array_merge( - $this->parseFeatureAnnotations($scenarioOutline->getFeature()), - $this->parseScenarioAnnotations($scenarioOutline), - $this->parseExampleAnnotations($scenarioOutline, $outlineExampleEvent->getIteration()) - ); - $annotationManager = new AnnotationManager($annotations); - $annotationManager->updateTestCaseEvent($event); - - Allure::lifecycle()->fire($event->withTitle($scenarioOutline->getTitle())); - } + $testId = new TestCaseId(); + $testId->testCaseIds = []; - /** - * @param ScenarioEvent $scenarioEvent - */ - public function afterScenario(ScenarioEvent $scenarioEvent) - { - $this->processScenarioResult($scenarioEvent->getResult()); - } + $severity = new Severity(); - /** - * @param OutlineExampleEvent $outlineExampleEvent - */ - public function afterOutlineExample(OutlineExampleEvent $outlineExampleEvent) - { - $this->processScenarioResult($outlineExampleEvent->getResult()); - } + $ignoredTags = []; - /** - * @param StepEvent $stepEvent - */ - public function beforeStep(StepEvent $stepEvent) - { - $step = $stepEvent->getStep(); - $event = new StepStartedEvent($step->getText()); - $event->withTitle(sprintf('%s %s', $step->getType(), $step->getText())); + $title = $scenarioNode instanceof ExampleNode ? $scenarioNode->getOutlineTitle() : $scenarioNode->getTitle(); + //$story->stories[] = $title; - Allure::lifecycle()->fire($event); + if (is_string($this->ignoredTags)) { + $ignoredTags = array_map('trim', explode(',', $this->ignoredTags)); + } elseif (is_array($this->ignoredTags)) { + $ignoredTags = $ignoredTags; } - public function afterStep(StepEvent $stepEvent) - { - switch ($stepEvent->getResult()) { - case StepEvent::FAILED: - $this->exception = $stepEvent->getException(); - $this->addFailedStep(); - break; - case StepEvent::UNDEFINED: - $this->exception = $stepEvent->getException(); - $this->addFailedStep(); - break; - case StepEvent::PENDING: - case StepEvent::SKIPPED: - $this->addCanceledStep(); - break; - case StepEvent::PASSED: - default: - $this->exception = null; - } + $annotation = array_merge($this->scopeAnnotation, $scenarioNode->getTags()); + foreach ($annotation as $tag) { - $this->addFinishedStep(); - } + if (in_array($tag, $ignoredTags)) { + continue; + } - /** - * @param string $outputDirectory - * @param boolean $deletePreviousResults - */ - private function prepareOutputDirectory($outputDirectory, $deletePreviousResults) - { - if (!file_exists($outputDirectory)) { - mkdir($outputDirectory, 0755, true); + if ($this->issueTagPrefix) { + if (stripos($tag, $this->issueTagPrefix) === 0) { + $issues->issueKeys[] = substr($tag, strlen($this->issueTagPrefix)); + continue; } + } - if ($deletePreviousResults) { - $files = glob($outputDirectory . DIRECTORY_SEPARATOR . '{,.}*', GLOB_BRACE); - foreach ($files as $file) { - if (is_file($file)) { - unlink($file); - } - } + if ($this->testIdTagPrefix) { + if (stripos($tag, $this->testIdTagPrefix) === 0) { + $testId->testCaseIds[] = substr($tag, strlen($this->testIdTagPrefix)); + continue; } - if (is_null(Provider::getOutputDirectory())) { - Provider::setOutputDirectory($outputDirectory); + } + + if (stripos($tag, $this->severity_key) === 0) { + $level = preg_replace("/$this->severity_key/", '', $tag); + try { + $level = ConstantChecker::validate('Yandex\Allure\Adapter\Model\SeverityLevel', $level); + $severity->level = $level; + } catch (AllureException $e) { + $severity->level = SeverityLevel::NORMAL; } - } + array_push($annotations, $severity); + continue; + } - /** - * @param integer $result - */ - protected function processScenarioResult($result) - { - switch ($result) { - case StepEvent::FAILED: - $this->addTestCaseFailed(); - break; - case StepEvent::UNDEFINED: - $this->addTestCaseBroken(); - break; - case StepEvent::PENDING: - $this->addTestCasePending(); - break; - case StepEvent::SKIPPED: - $this->addTestCaseCancelled(); - break; - case StepEvent::PASSED: - default: - $this->exception = null; - } - - $this->addTestCaseFinished(); + $story->stories[] = $tag; } - /** - * @param FeatureNode $featureNode - * - * @return array - */ - private function parseFeatureAnnotations(FeatureNode $featureNode) - { - $feature = new Features(); - $feature->featureNames = array($featureNode->getTitle()); - - $description = new Description(); - $description->type = DescriptionType::TEXT; - $description->value = $featureNode->getDescription(); - - return [ - $feature, - $description, - ]; + if ($story->getStories()) { + array_push($annotations, $story); + } + if ($issues->getIssueKeys()) { + array_push($annotations, $issues); } + if ($testId->getTestCaseIds()) { + array_push($annotations, $testId); + } + return $annotations; - /** - * @param ScenarioNode $scenario - * - * @return array - * @throws Exception - */ - private function parseScenarioAnnotations(ScenarioNode $scenario) - { - $annotations = []; - $story = new Stories(); - $story->stories = []; - - $issues = new Issues(); - $issues->issueKeys = []; - - $testId = new TestCaseId(); - $testId->testCaseIds = []; - - $ignoredTags = []; - $ignoredTagsParameter = $this->getParameter('ignored_tags'); - if (is_string($ignoredTagsParameter)) { - $ignoredTags = array_map('trim', explode(',', $ignoredTagsParameter)); - } elseif (is_array($ignoredTagsParameter)) { - $ignoredTags = $ignoredTagsParameter; - } - foreach ($scenario->getTags() as $tag) { - if (in_array($tag, $ignoredTags)) { - continue; - } - if ($severityPrefix = $this->getParameter('severity_tag_prefix')) { - if (stripos($tag, $severityPrefix) === 0) { - try { - $parsedSeverity = substr($tag, strlen($severityPrefix)); - - $severity = new Severity(); - $severity->level = $parsedSeverity; - - $annotations[] = $severity; - - continue; - } catch (AllureException $e) { - // do nothing and parse it as if it were regular tag - } - } - } - - if ($issuePrefix = $this->getParameter('issue_tag_prefix')) { - if (stripos($tag, $issuePrefix) === 0) { - $issues->issueKeys[] = substr($tag, strlen($issuePrefix)); - - continue; - } - } - - if ($testIdPrefix = $this->getParameter('test_id_tag_prefix')) { - if (stripos($tag, $testIdPrefix) === 0) { - $testId->testCaseIds[] = substr($tag, strlen($testIdPrefix)); - - continue; - } - } - - $story->stories[] = $tag; - } + } - if ($story->getStories()) { - array_push($annotations, $story); - } + protected function processScenarioResult($result) + { - if ($issues->getIssueKeys()) { - array_push($annotations, $issues); - } + if ($result instanceof ExceptionResult && $result->hasException()) { + $this->exception = $result->getException(); + } - if ($testId->getTestCaseIds()) { - array_push($annotations, $testId); - } + switch ($result->getResultCode()) { + case StepResult::FAILED: + $this->addTestCaseFailed(); + break; + case StepResult::UNDEFINED: + $this->addTestCaseBroken(); + break; + case StepResult::PENDING: + $this->addTestCasePending(); + break; + case StepResult::SKIPPED: + $this->addTestCaseCancelled(); + break; + case StepResult::PASSED: + default: + $this->exception = new \Exception('Error occurred out of test scope.'); - return $annotations; } + $this->addTestCaseFinished(); + } - /** - * @param OutlineNode $scenarioOutline - * @param integer $iteration - * - * @return array - */ - private function parseExampleAnnotations(OutlineNode $scenarioOutline, $iteration) - { - $parameters = []; - $examplesRow = $scenarioOutline->getExamples()->getHash(); - foreach ($examplesRow[$iteration] as $name => $value) { - $parameter = new Parameter(); - $parameter->name = $name; - $parameter->value = $value; - $parameters[] = $parameter; - } + protected function parseExampleAnnotations(array $tokens) + { - return $parameters; + $parameters = []; + + foreach ($tokens as $name => $value) { + $parameter = new Parameter(); + $parameter->name = $name; + $parameter->value = $value; + $parameters[] = $parameter; } - private function addCanceledStep() - { - $event = new StepCanceledEvent(); + return $parameters; + } - Allure::lifecycle()->fire($event); - } + protected function addAttachments() + { + array_walk($this->attachment, function ($path, $key) { + $this->addAttachment($path, $key . '-attachment'); + }); + } - private function addFinishedStep() - { - $event = new StepFinishedEvent(); + private function addCancelledStep() + { - Allure::lifecycle()->fire($event); - } + $event = new StepCanceledEvent(); + $this->getLifeCycle()->fire($event); + } - private function addFailedStep() - { - $event = new StepFailedEvent(); + private function addFinishedStep() + { - Allure::lifecycle()->fire($event); - } + $event = new StepFinishedEvent(); + $this->getLifeCycle()->fire($event); + } - private function addTestCaseFinished() - { - $this->exception; + private function addFailedStep() + { - $event = new TestCaseFinishedEvent(); - Allure::lifecycle()->fire($event); - } + $event = new StepFailedEvent(); + $this->getLifeCycle()->fire($event); + } - private function addTestCaseCancelled() - { - $event = new TestCaseCanceledEvent(); + private function addTestCaseFinished() + { - Allure::lifecycle()->fire($event); - } + $event = new TestCaseFinishedEvent(); + $this->getLifeCycle()->fire($event); + } - private function addTestCasePending() - { - $event = new TestCasePendingEvent(); + private function addTestCaseCancelled() + { - Allure::lifecycle()->fire($event); - } + $event = new TestCaseCanceledEvent(); + $this->getLifeCycle()->fire($event); + } - private function addTestCaseBroken() - { - $event = new TestCaseBrokenEvent(); - $event->withException($this->exception)->withMessage($this->exception->getMessage()); + private function addTestCasePending() + { - Allure::lifecycle()->fire($event); - } + $event = new TestCasePendingEvent(); + $this->getLifeCycle()->fire($event); + } - private function addTestCaseFailed() - { - $event = new TestCaseFailedEvent(); - $event->withException($this->exception)->withMessage($this->exception->getMessage()); + private function addTestCaseBroken() + { - Allure::lifecycle()->fire($event); - } -} \ No newline at end of file + $event = new TestCaseBrokenEvent(); + $this->getLifeCycle()->fire($event); + } + + private function addTestCaseFailed() + { + + $event = new TestCaseFailedEvent(); + $event->withException($this->exception) + ->withMessage($this->exception->getMessage()); + $this->addAttachments(); + + $this->getLifeCycle()->fire($event); + } +} diff --git a/src/Allure/Behat/Printer/DummyOutputPrinter.php b/src/Allure/Behat/Printer/DummyOutputPrinter.php new file mode 100644 index 0000000..d932cc1 --- /dev/null +++ b/src/Allure/Behat/Printer/DummyOutputPrinter.php @@ -0,0 +1,148 @@ +outputPath = $path; + } + + /** + * Returns output path. + * + * @return null|string + * + * @deprecated since 3.1, to be removed in 4.0 + */ + public function getOutputPath() + { + return $this->outputPath; + } + + /** + * Sets output styles. + * + * @param array $styles + */ + public function setOutputStyles(array $styles) + { + $this->outputStyles = $styles; + } + + /** + * Returns output styles. + * + * @return array + * + * @deprecated since 3.1, to be removed in 4.0 + */ + public function getOutputStyles() + { + return $this->outputStyles; + } + + /** + * Forces output to be decorated. + * + * @param Boolean $decorated + */ + public function setOutputDecorated($decorated) + { + // TODO: Implement setOutputDecorated() method. + } + + /** + * Returns output decoration status. + * + * @return null|Boolean + * + * @deprecated since 3.1, to be removed in 4.0 + */ + public function isOutputDecorated() + { + return TRUE; + } + + /** + * Sets output verbosity level. + * + * @param integer $level + */ + public function setOutputVerbosity($level) + { + // TODO: Implement setOutputVerbosity() method. + } + + /** + * Returns output verbosity level. + * + * @return integer + * + * @deprecated since 3.1, to be removed in 4.0 + */ + public function getOutputVerbosity() + { + // TODO: Implement getOutputVerbosity() method. + } + + /** + * Writes message(s) to output stream. + * + * @param string|array $messages message or array of messages + */ + public function write($messages) + { + // TODO: Implement write() method. + } + + /** + * Writes newlined message(s) to output stream. + * + * @param string|array $messages message or array of messages + */ + public function writeln($messages = '') + { + // TODO: Implement writeln() method. + } + + /** + * Clear output stream, so on next write formatter will need to init (create) + * it again. + */ + public function flush() + { + // TODO: Implement flush() method. + } + +}