From 9230ce240548a720991076e2b96a707e8c956ef6 Mon Sep 17 00:00:00 2001 From: Ingo Schommer Date: Sat, 2 Aug 2014 18:30:27 +1200 Subject: [PATCH] API Upgrade to behat 3 --- .editorconfig | 3 +- .travis.yml | 9 +- README.md | 148 ++++--- composer.json | 107 ++--- config/silverstripe.yml | 29 ++ .../Compiler/CoreInitializationPass.php | 14 +- src/Compiler/MinkExtensionBaseUrlPass.php | 40 ++ .../Context/BasicContext.php | 374 ++++++++++++------ .../Context/EmailContext.php | 68 ++-- .../Context/FixtureContext.php | 207 +++++++--- .../SilverStripeAwareInitializer.php | 29 +- src/Context/LoginContext.php | 181 +++++++++ src/Context/MainContextAwareTrait.php | 63 +++ src/Context/RetryableContextTrait.php | 25 ++ src/Context/SilverStripeAwareContext.php | 71 ++++ .../Context/SilverStripeContext.php | 160 +++++--- src/Controllers/ModuleCommandTrait.php | 28 ++ .../ModuleInitialisationController.php | 288 ++++++++++++++ src/Controllers/ModuleSuiteLocator.php | 171 ++++++++ src/Extension.php | 164 ++++++++ src/MinkExtension.php | 23 ++ .../Compiler/MinkExtensionBaseUrlPass.php | 107 ----- .../Console/Processor/InitProcessor.php | 248 ------------ .../Console/Processor/LocatorProcessor.php | 125 ------ .../ModuleContextClassGuesser.php | 58 --- .../BehatExtension/Context/LoginContext.php | 187 --------- .../SilverStripeAwareContextInterface.php | 34 -- src/SilverStripe/BehatExtension/Extension.php | 122 ------ .../BehatExtension/MinkExtension.php | 19 - .../BehatExtension/services/silverstripe.yml | 36 -- .../Utility/TestMailer.php | 13 - templates/FeatureContext.ss | 22 ++ templates/SkeletonFeature.ss | 14 + templates/config-base.yml | 10 + tests/Context/FixtureContextTest.php | 0 .../SilverStripeContextTest.php | 26 +- .../FeatureContext.php | 11 + 37 files changed, 1866 insertions(+), 1368 deletions(-) create mode 100644 config/silverstripe.yml rename src/{SilverStripe/BehatExtension => }/Compiler/CoreInitializationPass.php (60%) create mode 100644 src/Compiler/MinkExtensionBaseUrlPass.php rename src/{SilverStripe/BehatExtension => }/Context/BasicContext.php (78%) rename src/{SilverStripe/BehatExtension => }/Context/EmailContext.php (87%) rename src/{SilverStripe/BehatExtension => }/Context/FixtureContext.php (78%) rename src/{SilverStripe/BehatExtension => }/Context/Initializer/SilverStripeAwareInitializer.php (87%) create mode 100644 src/Context/LoginContext.php create mode 100644 src/Context/MainContextAwareTrait.php create mode 100644 src/Context/RetryableContextTrait.php create mode 100644 src/Context/SilverStripeAwareContext.php rename src/{SilverStripe/BehatExtension => }/Context/SilverStripeContext.php (79%) create mode 100644 src/Controllers/ModuleCommandTrait.php create mode 100644 src/Controllers/ModuleInitialisationController.php create mode 100644 src/Controllers/ModuleSuiteLocator.php create mode 100644 src/Extension.php create mode 100644 src/MinkExtension.php delete mode 100644 src/SilverStripe/BehatExtension/Compiler/MinkExtensionBaseUrlPass.php delete mode 100644 src/SilverStripe/BehatExtension/Console/Processor/InitProcessor.php delete mode 100644 src/SilverStripe/BehatExtension/Console/Processor/LocatorProcessor.php delete mode 100644 src/SilverStripe/BehatExtension/Context/ClassGuesser/ModuleContextClassGuesser.php delete mode 100644 src/SilverStripe/BehatExtension/Context/LoginContext.php delete mode 100644 src/SilverStripe/BehatExtension/Context/SilverStripeAwareContextInterface.php delete mode 100644 src/SilverStripe/BehatExtension/Extension.php delete mode 100644 src/SilverStripe/BehatExtension/MinkExtension.php delete mode 100644 src/SilverStripe/BehatExtension/services/silverstripe.yml rename src/{SilverStripe/BehatExtension => }/Utility/TestMailer.php (86%) create mode 100644 templates/FeatureContext.ss create mode 100644 templates/SkeletonFeature.ss create mode 100644 templates/config-base.yml delete mode 100644 tests/Context/FixtureContextTest.php rename tests/{Context => php}/SilverStripeContextTest.php (76%) create mode 100644 tests/php/SilverStripeContextTest/FeatureContext.php diff --git a/.editorconfig b/.editorconfig index f1d3982..d646cda 100644 --- a/.editorconfig +++ b/.editorconfig @@ -10,8 +10,9 @@ indent_style = space insert_final_newline = true trim_trailing_whitespace = true -[{*.yml,package.json}] +[*.{yml,js,json,css,scss,eslintrc}] indent_size = 2 +indent_style = space # The indent size used in the package.json file cannot be changed: # https://github.com/npm/npm/pull/3180#issuecomment-16336516 diff --git a/.travis.yml b/.travis.yml index 911a759..11ceb94 100644 --- a/.travis.yml +++ b/.travis.yml @@ -16,17 +16,16 @@ matrix: env: PHPUNIT_TEST=1 - php: 7.0 env: PHPUNIT_TEST=1 - - php: 7.1 + - php: 7.1.2 env: PHPUNIT_TEST=1 before_script: - composer validate - composer install --dev --prefer-dist - composer require silverstripe/config:1.0.x-dev silverstripe/framework:4.0.x-dev --prefer-dist - - "if [ \"$PHPCS_TEST\" = \"1\" ]; then pyrus install pear/PHP_CodeSniffer; fi" + - if [[ $PHPCS_TEST ]]; then pyrus install pear/PHP_CodeSniffer; fi - phpenv rehash - script: - - "if [ \"$PHPUNIT_TEST\" = \"1\" ]; then vendor/bin/phpunit tests; fi" - - "if [ \"$PHPCS_TEST\" = \"1\" ]; then phpcs --standard=PSR2 -n src/ tests/; fi" + - if [[ $PHPUNIT_TEST ]]; then vendor/bin/phpunit tests/php; fi + - if [[ $PHPCS_TEST ]]; then composer run-script lint; fi diff --git a/README.md b/README.md index 05fab3c..7026628 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,9 @@ ## Overview [Behat](http://behat.org) is a testing framework for behaviour-driven development. -Because it primarily interacts with your website through a browser, +Because it primarily interacts with your website through a browser, you don't need any specific integration tools to get it going with -a basic SilverStripe website, simply follow the +a basic SilverStripe website, simply follow the [standard Behat usage instructions](http://docs.behat.org/). This extension comes in handy if you want to go beyond @@ -40,7 +40,7 @@ Note: The extension has only been tested with the `selenium2` Mink driver. Simply [install SilverStripe through Composer](http://doc.silverstripe.org/framework/en/installation/composer). Skip this step if adding the module to an existing project. - composer create-project silverstripe/installer my-test-project 3.x-dev + composer create-project silverstripe/installer my-test-project 4.x-dev Switch to the newly created webroot, and add the SilverStripe Behat extension. @@ -56,13 +56,13 @@ It might be older than your currently installed Firefox. It's important to have a browser that's [supported by Selenium-Webdriver](http://docs.seleniumhq.org/docs/01_introducing_selenium.jsp#selenium-webdriver) Now install the SilverStripe project as usual by opening it in a browser and following the instructions. -Protip: You can skip this step by using `[SS_DATABASE_CHOOSE_NAME]` in a global -[`_ss_environment.php`](http://doc.silverstripe.org/framework/en/topics/environment-management) +Protip: You can skip this step by using `[SS_DATABASE_CHOOSE_NAME]` in a global +[`_ss_environment.php`](http://doc.silverstripe.org/framework/en/topics/environment-management) file one level above the webroot. Unless you have [`$_FILE_TO_URL_MAPPING`](http://doc.silverstripe.org/framework/en/topics/commandline#configuration) set up, you also need to specify the URL for your webroot. Either add it to the existing `behat.yml` configuration file -in your project root, or set is as an environment variable in your terminal session: +in your project root, or set is as an environment variable in your terminal session: export BEHAT_PARAMS="extensions[SilverStripe\BehatExtension\MinkExtension][base_url]=http://localhost/" @@ -94,10 +94,6 @@ Now you can run the tests (for example for the `framework` module): vendor/bin/behat @framework -In order to run specific tests only, use their feature file name: - - vendor/bin/behat @framework/login.feature - Or even run a single scenario by it's name (supports regular expressions): vendor/bin/behat --name 'My scenario title' @framework @@ -118,77 +114,77 @@ The SilverStripe installer already comes with a YML configuration which is ready to run tests on a locally hosted Selenium server, located in the project root as `behat.yml`. -You'll need to customize at least the `base_url` setting to match the URL where -the tested SilverStripe instance is hosted locally. This +You should ensure that you have configured SS_BASE_URL in your `.env`. Generic Mink configuration settings are placed in `SilverStripe\BehatExtension\MinkExtension`, which is a subclass of `Behat\MinkExtension\Extension`. Overview of settings (all in the `extensions.SilverStripe\BehatExtension\Extension` path): - * `framework_path`: Path to the SilverStripe Framework folder. It supports both absolute and relative (to `behat.yml` file) paths. - * `extensions.Behat\MinkExtension\Extension.base_url`: You will probably need to change the base URL that is used during the test process. -It is used every time you use relative URLs in your feature descriptions. -It will also be used by [file to URL mapping](http://doc.silverstripe.org/framework/en/topics/commandline#configuration) in `SilverStripeExtension`. - * `extensions.Behat\MinkExtension\Extension.files_path`: Change to support file uploads in your tests. Currently only absolute paths are supported. * `ajax_steps`: Because SilverStripe uses AJAX requests quite extensively, we had to invent a way to deal with them more efficiently and less verbose than just Optional `ajax_steps` is used to match steps defined there so they can be "caught" by [special AJAX handlers](http://blog.scur.pl/2012/06/ajax-callback-support-behat-mink/) that tweak the delays. You can either use a pipe delimited string or a list of substrings that match step definition. - * `ajax_timeout`: Milliseconds after which an Ajax request is regarded as timed out, + * `ajax_timeout`: Milliseconds after which an Ajax request is regarded as timed out, and the script continues with its assertions to avoid a deadlock (Default: 5000). * `screenshot_path`: Absolute path used to store screenshot of a last known state -of a failed step. +of a failed step. Screenshot names within that directory consist of feature file filename and line number that failed. -Example: behat.yml +Example: behat.yml default: - context: - class: SilverStripe\MyModule\Test\Behaviour\FeatureContext + suites: + framework: + paths: + - %paths.modules.framework%/tests/behat/features + contexts: + - SilverStripe\Framework\Tests\Behaviour\FeatureContext + - SilverStripe\Framework\Tests\Behaviour\CmsFormsContext + - SilverStripe\Framework\Tests\Behaviour\CmsUiContext + - SilverStripe\BehatExtension\Context\BasicContext + - SilverStripe\BehatExtension\Context\EmailContext + - SilverStripe\BehatExtension\Context\LoginContext + - + SilverStripe\BehatExtension\Context\FixtureContext: + - %paths.modules.framework%/tests/behat/features/files/ extensions: - SilverStripe\BehatExtension\Extension: - screenshot_path: %behat.paths.base%/artifacts/screenshots - SilverStripe\BehatExtension\MinkExtension: - # Adjust this to your local environment - base_url: http://localhost/ - default_session: selenium2 - javascript_session: selenium2 - selenium2: - browser: firefox + SilverStripe\BehatExtension\MinkExtension: + default_session: selenium2 + javascript_session: selenium2 + selenium2: + browser: firefox + SilverStripe\BehatExtension\Extension: + screenshot_path: %paths.base%/artifacts/screenshots ## Module Initialization You're all set to start writing features now! Simply create `*.feature` files anywhere in your codebase, and run them as shown above. We recommend the folder -structure of `tests/behat/features`, since its consistent with the common location +structure of `tests/behat/features`, since its consistent with the common location of SilverStripe's PHPUnit tests. Behat tests rely on a `FeatureContext` class which contains step definitions, -and can be composed of other subcontexts, e.g. for SilverStripe-specific CMS steps +and can be composed of other subcontexts, e.g. for SilverStripe-specific CMS steps (details on [behat.org](http://docs.behat.org/quick_intro.html#the-context-class-featurecontext)). Since step definitions are quite domain specific, its likely that you'll need your own context. The SilverStripe Behat extension provides an initializer script which generates a template in the recommended folder structure: - vendor/bin/behat --init @mymodule + vendor/bin/behat --init @mymodule --namespace="MyVendor\MyModule" -You'll now have a class located in `mymodule/tests/behat/features/bootstrap/Context/FeatureContext.php`, -as well as a folder for your features with `mymodule/tests/behat/features`. -The class is namespaced, and defaults to the module name. You can customize this: + Note: namespace is mandatory - vendor/bin/behat --namespace='MyVendor\MyModule' --init @mymodule - -In this case, you'll need to pass in the namespace when running the features as well -(at least until SilverStripe modules allow declaring a namespace). - - vendor/bin/behat --namespace='MyVendor\MyModule' @mymodule +You'll now have a class located in `mymodule/tests/behat/src/FeatureContext.php`, +which will have a psr-4 class mapping added to composer.json by default. +Also a folder for your features with `mymodule/tests/behat/features` will be created. +A `mymodule/behat.yml` is built, with a default suite named after the module. ## Available Step Definitions The extension comes with several `BehatContext` subclasses come with some extra step defintions. -Some of them are just helpful in general website testing, other's are specific to SilverStripe. +Some of them are just helpful in general website testing, other's are specific to SilverStripe. To find out all available steps (and the files they are defined in), run the following: vendor/bin/behat @mymodule --definitions=i @@ -200,7 +196,7 @@ In addition to the dynamic list, a cheatsheet of available steps can be found at ## Fixtures Since each test run creates a new database, you can't rely on existing state unless -you explicitly define it. +you explicitly define it. ### Database Defaults @@ -222,22 +218,22 @@ use the inline definition syntax. The following example shows some syntax variat Background: # Creates a new page without data. Can be accessed later under this identifier - Given a "page" "Page 1" + Given a "page" "Page 1" # Uses a custom RegistrationPage type - And an "error page" "Register" - # Creates a page with inline properties - And a "page" "Page 2" with "URLSegment"="page-1" and "Content"="my page 1" - # Field names can be tabular, and based on DataObject::$field_labels + And an "error page" "Register" + # Creates a page with inline properties + And a "page" "Page 2" with "URLSegment"="page-1" and "Content"="my page 1" + # Field names can be tabular, and based on DataObject::$field_labels And the "page" "Page 3" has the following data | Content | | | My Property | foo | | My Boolean | bar | # Pages are published by default, can be explicitly unpublished - And the "page" "Page 1" is not published + And the "page" "Page 1" is not published # Create a hierarchy, and reference a record created earlier - And the "page" "Page 1.1" is a child of a "page" "Page 1" - # Specific page type step - And a "page" "My Redirect" which redirects to a "page" "Page 1" + And the "page" "Page 1.1" is a child of a "page" "Page 1" + # Specific page type step + And a "page" "My Redirect" which redirects to a "page" "Page 1" And a "member" "Website User" with "FavouritePage"="=>Page.Page 1" @javascript @@ -247,9 +243,9 @@ use the inline definition syntax. The following example shows some syntax variat Then I should see "Page 1" in CMS Tree * Fixtures are created where you defined them. If you want the fixtures to be created - before every scenario, define them in [Background](http://docs.behat.org/guides/1.gherkin.html#backgrounds). + before every scenario, define them in [Background](http://docs.behat.org/guides/1.gherkin.html#backgrounds). If you want them to be created only when a particular scenario runs, define them there. - * Fixtures are cleared between scenarios. + * Fixtures are cleared between scenarios. * The basic syntax works for all `DataObject` subclasses, but some specific notations like "is not published" requires extensions like `Hierarchy` to be applied to the class * Record types, identifiers, property names and property values need to be quoted @@ -265,9 +261,10 @@ use the inline definition syntax. The following example shows some syntax variat ### Directory Structure As a convention, SilverStripe Behat tests live in a `tests/behat` subfolder -of your module. You can create it with the following command: +of your module. You can create it with the following commands: - mkdir -p mymodule/tests/behat/features/bootstrap/MyModule/Test/Behaviour + mkdir -p mymodule/tests/behat/features/ + mkdir -p mymodule/tests/behat/src/ ### FeatureContext @@ -276,29 +273,20 @@ here as well. The only major difference is the base class from which to extend your own `FeatureContext`: It should be `SilverStripeContext` rather than `BehatContext`. -Example: mymodule/tests/behat/features/bootstrap/MyModule/Test/Behaviour/FeatureContext.php +Example: mymodule/tests/behat/src/FeatureContext.php useContext('BasicContext', new BasicContext($parameters)); - $this->useContext('LoginContext', new LoginContext($parameters)); - - parent::__construct($parameters); - } } ### Screen Size -In some Selenium drivers you can +In some Selenium drivers you can define the desired browser window size through a `capabilities` definition. By default, Selenium doesn't support this though, so we've added a workaround through an environment variable: @@ -308,7 +296,7 @@ through an environment variable: ### Inspecting PHP sessions Behat is executed from CLI, which in turn triggers web requests in a browser. -This browser session is associated PHP session information such as the logged-in user. +This browser session is associated PHP session information such as the logged-in user. After every request, the session information is persisted on disk as part of the `TestSessionEnvironment`, in order to share it with Behat CLI. @@ -360,7 +348,7 @@ It is possible to include your own fixtures, it is explained further. ### Why do tests pass in a fresh installation, but fail in my own project? Because we're testing the interface directly, any changes to the -viewed elements have the potential to disrupt testing. +viewed elements have the potential to disrupt testing. By building a test database from scratch, we're trying to minimize this impact. Some examples where things can go wrong nevertheless: @@ -388,7 +376,7 @@ methods, it is possible to delay the step execution by adding the following step And I put a breakpoint -This will stop the execution of the tests until you press the return key in the +This will stop the execution of the tests until you press the return key in the terminal. This is very useful when you want to look at the error or developer console inside the browser or if you want to interact with the session page manually. @@ -597,7 +585,7 @@ It's based on the `vendor/bin/behat -di @cms` output. Given /^I (?:press|follow) the "([^"]*)" (?:button|link), confirming the dialog$/ Given /^I (?:press|follow) the "([^"]*)" (?:button|link), dismissing the dialog$/ - + Given /^I (click|double click) "([^"]*)" in the "([^"]*)" element, confirming the dialog$/ Given /^I (click|double click) "([^"]*)" in the "([^"]*)" element, dismissing the dialog$/ @@ -622,7 +610,7 @@ It's based on the `vendor/bin/behat -di @cms` output. ### CMS UI Then /^I should see an edit page form$/ - + Then /^I should see the CMS$/ Then /^I should see a "([^"]*)" notice$/ @@ -679,7 +667,7 @@ It's based on the `vendor/bin/behat -di @cms` output. Given /^(?:(an|a|the) )"group" "(?[^"]+)" (?:(with|has)) permissions (?.*)$/ - Example: Given a "group" "Admin" with permissions "Access to 'Pages' section" and "Access to 'Files' section" - + Given /^I assign (?:(an|a|the) )"(?[^"]+)" "(?[^"]+)" to (?:(an|a|the) )"(?[^"]+)" "(?[^"]+)"$/ - Example: I assign the "TaxonomyTerm" "For customers" to the "Page" "Page1" @@ -698,7 +686,7 @@ It's based on the `vendor/bin/behat -di @cms` output. ### Email Given /^there should (not |)be an email (to|from) "([^"]*)"$/ - + Given /^there should (not |)be an email (to|from) "([^"]*)" titled "([^"]*)"$/ Given /^the email should (not |)contain "([^"]*)"$/ @@ -723,7 +711,7 @@ It's based on the `vendor/bin/behat -di @cms` output. When /^I click on the http link "([^"]*)" in the email$/ - Example: When I click on the http link "http://localhost/changepassword" in the email - + ### Transformations Behat [transformations](http://docs.behat.org/guides/2.definitions.html#step-argument-transformations) @@ -731,8 +719,8 @@ have the ability to change step arguments based on their original value, for example to cast any argument matching the `\d` regex into an actual PHP integer. * `/^(?:(the|a)) time of (?.*)$/`: Transforms relative time statements compatible with [strtotime()](http://www.php.net/manual/en/datetime.formats.relative.php). Example: "the time of 1 hour ago" might return "22:00:00" if its currently "23:00:00". - * `/^(?:(the|a)) date of (?.*)$/`: Transforms relative date statements compatible with [strtotime()](http://www.php.net/manual/en/datetime.formats.relative.php). Example: "the date of 2 days ago" might return "2013-10-10" if its currently the 12th of October 2013. - * `/^(?:(the|a)) datetime of (?.*)$/`: Transforms relative date and time statements compatible with [strtotime()](http://www.php.net/manual/en/datetime.formats.relative.php). Example: "the datetime of 2 days ago" might return "2013-10-10 23:00:00" if its currently the 12th of October 2013. + * `/^(?:(the|a)) date of (?.*)$/`: Transforms relative date statements compatible with [strtotime()](http://www.php.net/manual/en/datetime.formats.relative.php). Example: "the date of 2 days ago" might return "2013-10-10" if its currently the 12th of October 2013. + * `/^(?:(the|a)) datetime of (?.*)$/`: Transforms relative date and time statements compatible with [strtotime()](http://www.php.net/manual/en/datetime.formats.relative.php). Example: "the datetime of 2 days ago" might return "2013-10-10 23:00:00" if its currently the 12th of October 2013. ## Useful resources diff --git a/composer.json b/composer.json index 0371f61..7af591c 100644 --- a/composer.json +++ b/composer.json @@ -1,52 +1,59 @@ { - "name": "silverstripe/behat-extension", - "type": "behat-extension", - "description": "SilverStripe framework extension for Behat", - "keywords": ["framework", "web", "bdd", "silverstripe"], - "homepage": "http://silverstripe.org", - "license": "MIT", - "authors": [ - { - "name": "Michal Ochman", - "email": "ochman.d.michal@gmail.com" - }, - { - "name": "Ingo Schommer", - "email": "ingo@silverstripe.com" - } - ], - - "require": { - "php": ">=5.3.3", - "phpunit/phpunit": "^4.8 || ^5.7", - "behat/behat": "~2.5.0", - "behat/mink": "~1.6.0", - "behat/mink-extension": "~1.3.0", - "behat/mink-selenium2-driver": "~1.2.0", - "symfony/dom-crawler": "~2.0", - "silverstripe/testsession": "2.0.0-alpha6", - "silverstripe/framework": "^4.0.0@dev" - }, - - "autoload": { - "psr-0": { - "SilverStripe\\BehatExtension": "src/" - } - }, - "autoload-dev": { - "psr-0": { - "SilverStripe\\BehatExtension\\Tests": "tests/" - }, - "classmap": [ - "framework", - "vendor/phpunit/phpunit" - ] - }, - "extra": { - "branch-alias": { - "dev-master": "2.2.x-dev" - } - }, - "prefer-stable": true, - "minimum-stability": "dev" + "name": "silverstripe/behat-extension", + "type": "behat-extension", + "description": "SilverStripe framework extension for Behat", + "keywords": [ + "framework", + "web", + "bdd", + "silverstripe" + ], + "homepage": "http://silverstripe.org", + "license": "MIT", + "authors": [ + { + "name": "Michal Ochman", + "email": "ochman.d.michal@gmail.com" + }, + { + "name": "Ingo Schommer", + "email": "ingo@silverstripe.com" + } + ], + "require": { + "php": ">=5.6", + "phpunit/phpunit": "^5.7", + "behat/behat": "^3.2", + "behat/mink": "^1.7", + "behat/mink-extension": "^2.1", + "behat/mink-selenium2-driver": "^1.3", + "symfony/dom-crawler": "^3", + "silverstripe/testsession": "2.0.0-alpha6", + "silverstripe/framework": "^4@dev", + "symfony/finder": "^3.2" + }, + "autoload": { + "psr-4": { + "SilverStripe\\BehatExtension\\": "src/" + } + }, + "autoload-dev": { + "psr-0": { + "SilverStripe\\BehatExtension\\Tests\\": "tests/php/" + }, + "classmap": [ + "framework", + "vendor/phpunit/phpunit" + ] + }, + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "scripts": { + "lint": "phpcs --standard=PSR2 -n src/ tests/php/" + }, + "prefer-stable": true, + "minimum-stability": "dev" } diff --git a/config/silverstripe.yml b/config/silverstripe.yml new file mode 100644 index 0000000..b1668d3 --- /dev/null +++ b/config/silverstripe.yml @@ -0,0 +1,29 @@ +parameters: + silverstripe_extension.context.initializer.class: SilverStripe\BehatExtension\Context\Initializer\SilverStripeAwareInitializer +# Moved to PHP. See Extension::load() +# console.processor.locator.class: SilverStripe\BehatExtension\Controllers\LocatorProcessor + +# Custom init processory temporarily removed +# console.processor.init.class: SilverStripe\BehatExtension\Controllers\InitProcessor + silverstripe_extension.ajax_steps: ~ + silverstripe_extension.ajax_timeout: ~ + silverstripe_extension.admin_url: ~ + silverstripe_extension.login_url: ~ + silverstripe_extension.screenshot_path: ~ + silverstripe_extension.module: + silverstripe_extension.region_map: ~ + silverstripe_extension.context.namespace_suffix: Tests\Behaviour + silverstripe_extension.context.features_path: tests/behat/features/ + silverstripe_extension.context.class_path: tests/behat/src/ +services: + silverstripe_extension.context.initializer: + class: %silverstripe_extension.context.initializer.class% + calls: + - [setAjaxSteps, [%silverstripe_extension.ajax_steps%]] + - [setAjaxTimeout, [%silverstripe_extension.ajax_timeout%]] + - [setAdminUrl, [%silverstripe_extension.admin_url%]] + - [setLoginUrl, [%silverstripe_extension.login_url%]] + - [setScreenshotPath, [%silverstripe_extension.screenshot_path%]] + - [setRegionMap, [%silverstripe_extension.region_map%]] + tags: + - { name: context.initializer } diff --git a/src/SilverStripe/BehatExtension/Compiler/CoreInitializationPass.php b/src/Compiler/CoreInitializationPass.php similarity index 60% rename from src/SilverStripe/BehatExtension/Compiler/CoreInitializationPass.php rename to src/Compiler/CoreInitializationPass.php index deeec6a..fd9e915 100644 --- a/src/SilverStripe/BehatExtension/Compiler/CoreInitializationPass.php +++ b/src/Compiler/CoreInitializationPass.php @@ -2,7 +2,7 @@ namespace SilverStripe\BehatExtension\Compiler; -use SilverStripe\Dev\SapphireTest; +use SilverStripe\Core\Manifest\ModuleLoader; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; @@ -26,11 +26,21 @@ class CoreInitializationPass implements CompilerPassInterface require_once('Core/Core.php'); // Include bootstrap file - $bootstrapFile = $container->getParameter('behat.silverstripe_extension.bootstrap_file'); + $bootstrapFile = $container->getParameter('silverstripe_extension.bootstrap_file'); if ($bootstrapFile) { require_once $bootstrapFile; } + // Register all paths + foreach (ModuleLoader::instance()->getManifest()->getModules() as $module) { + $container->setParameter('paths.modules.'.$module->getShortName(), $module->getPath()); + $composerName = $module->getComposerName(); + if ($composerName) { + list($vendor,$name) = explode('/', $composerName); + $container->setParameter('paths.modules.'.$vendor.'.'.$name, $module->getPath()); + } + } + unset($_GET['flush']); // Remove the error handler so that PHPUnit can add its own diff --git a/src/Compiler/MinkExtensionBaseUrlPass.php b/src/Compiler/MinkExtensionBaseUrlPass.php new file mode 100644 index 0000000..d26779d --- /dev/null +++ b/src/Compiler/MinkExtensionBaseUrlPass.php @@ -0,0 +1,40 @@ + + */ +class MinkExtensionBaseUrlPass implements CompilerPassInterface +{ + /** + * Passes MinkExtension's base_url parameter + * + * @param ContainerBuilder $container + */ + public function process(ContainerBuilder $container) + { + // Set url from environment + $baseURL = getenv('SS_BASE_URL'); + if (!$baseURL) { + throw new InvalidArgumentException( + '"base_url" not configured. Please specify it in your .env config with SS_BASE_URL' + ); + } + $container->setParameter('mink.base_url', $baseURL); + + // The Behat\MinkExtension\Extension class copies configuration into an internal hash, + // we need to follow this pattern to propagate our changes. + $parameters = $container->getParameter('mink.parameters'); + $parameters['base_url'] = $container->getParameter('mink.base_url'); + $container->setParameter('mink.parameters', $parameters); + } +} diff --git a/src/SilverStripe/BehatExtension/Context/BasicContext.php b/src/Context/BasicContext.php similarity index 78% rename from src/SilverStripe/BehatExtension/Context/BasicContext.php rename to src/Context/BasicContext.php index ea323a4..536ff8e 100644 --- a/src/SilverStripe/BehatExtension/Context/BasicContext.php +++ b/src/Context/BasicContext.php @@ -2,19 +2,23 @@ namespace SilverStripe\BehatExtension\Context; -use Behat\Behat\Context\BehatContext; -use Behat\Behat\Context\Step; -use Behat\Behat\Event\StepEvent; -use Behat\Behat\Event\ScenarioEvent; +use Behat\Behat\Context\Context; +use Behat\Behat\Definition\Call; +use Behat\Behat\Hook\Scope\AfterScenarioScope; +use Behat\Behat\Hook\Scope\AfterStepScope; +use Behat\Behat\Hook\Scope\BeforeScenarioScope; +use Behat\Behat\Hook\Scope\BeforeStepScope; +use Behat\Behat\Hook\Scope\StepScope; +use Behat\Gherkin\Node\ScenarioNode; use Behat\Mink\Driver\Selenium2Driver; use Behat\Mink\Element\NodeElement; use Behat\Mink\Session; -use Behat\MinkExtension\Context\RawMinkContext; +use Behat\Testwork\Tester\Result\TestResult; +use Exception; use SilverStripe\Assets\File; use SilverStripe\Assets\Filesystem; - -// PHPUnit -require_once BASE_PATH . '/vendor/phpunit/phpunit/src/Framework/Assert/Functions.php'; +use WebDriver\Exception as WebDriverException; +use WebDriver\Session as WebDriverSession; /** * BasicContext @@ -24,13 +28,21 @@ require_once BASE_PATH . '/vendor/phpunit/phpunit/src/Framework/Assert/Functions * Handles redirections. * Handles AJAX enabled links, buttons and forms - jQuery is assumed. */ -class BasicContext extends BehatContext +class BasicContext implements Context { - protected $context; + use MainContextAwareTrait; + + /** + * Work-around for https://github.com/Behat/Behat/issues/653 + * + * @var ScenarioNode + */ + protected $currentScenario = null; /** * Date format in date() syntax - * @var String + * + * @var string */ protected $dateFormat = 'Y-m-d'; @@ -46,18 +58,6 @@ class BasicContext extends BehatContext */ protected $datetimeFormat = 'Y-m-d H:i:s'; - /** - * Initializes context. - * Every scenario gets it's own context object. - * - * @param array $parameters context parameters (set them up through behat.yml) - */ - public function __construct(array $parameters) - { - // Initialize your context here - $this->context = $parameters; - } - /** * Get Mink session from MinkContext * @@ -72,13 +72,42 @@ class BasicContext extends BehatContext } /** - * @AfterStep ~@modal + * 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 * * Excluding scenarios with @modal tag is required, * because modal dialogs stop any JS interaction + * + * @param AfterStepScope $event */ - public function appendErrorHandlerBeforeStep(StepEvent $event) + public function appendErrorHandlerBeforeStep(AfterStepScope $event) { + // Manually exclude @modal + if ($this->stepHasTag($event, 'modal')) { + return; + } + try { $javascript = <<getSession()->executeScript($javascript); - } catch (\WebDriver\Exception $e) { + } catch (WebDriverException $e) { $this->logException($e); } } /** - * @AfterStep ~@modal + * @AfterStep * * Excluding scenarios with @modal tag is required, * because modal dialogs stop any JS interaction + * + * @param AfterStepScope $event */ - public function readErrorHandlerAfterStep(StepEvent $event) + public function readErrorHandlerAfterStep(AfterStepScope $event) { + // Manually exclude @modal + if ($this->stepHasTag($event, 'modal')) { + return; + } try { $page = $this->getSession()->getPage(); @@ -129,7 +166,7 @@ if ('undefined' !== typeof window.jQuery) { JS; $this->getSession()->executeScript($javascript); - } catch (\WebDriver\Exception $e) { + } catch (WebDriverException $e) { $this->logException($e); } } @@ -140,9 +177,14 @@ JS; * Event handlers are removed after one run. * * @BeforeStep + * @param BeforeStepScope $event */ - public function handleAjaxBeforeStep(StepEvent $event) + public function handleAjaxBeforeStep(BeforeStepScope $event) { + // Manually exclude @modal + if ($this->stepHasTag($event, 'modal')) { + return; + } try { $ajaxEnabledSteps = $this->getMainContext()->getAjaxSteps(); $ajaxEnabledSteps = implode('|', array_filter($ajaxEnabledSteps)); @@ -176,7 +218,7 @@ if ('undefined' !== typeof window.jQuery && 'undefined' !== typeof window.jQuery JS; $this->getSession()->wait(500); // give browser a chance to process and render response $this->getSession()->executeScript($javascript); - } catch (\WebDriver\Exception $e) { + } catch (WebDriverException $e) { $this->logException($e); } } @@ -187,10 +229,15 @@ JS; * * Don't unregister handler if we're dealing with modal windows * - * @AfterStep ~@modal + * @AfterStep + * @param AfterStepScope $event */ - public function handleAjaxAfterStep(StepEvent $event) + public function handleAjaxAfterStep(AfterStepScope $event) { + // Manually exclude @modal + if ($this->stepHasTag($event, 'modal')) { + return; + } try { $ajaxEnabledSteps = $this->getMainContext()->getAjaxSteps(); $ajaxEnabledSteps = implode('|', array_filter($ajaxEnabledSteps)); @@ -209,7 +256,7 @@ window.jQuery(document).off('ajaxSuccess.ss.test.behaviour'); } JS; $this->getSession()->executeScript($javascript); - } catch (\WebDriver\Exception $e) { + } catch (WebDriverException $e) { $this->logException($e); } } @@ -233,15 +280,18 @@ JS; * Works only with Selenium2Driver. * * @AfterStep + * @param AfterStepScope $event */ - public function takeScreenshotAfterFailedStep(StepEvent $event) + public function takeScreenshotAfterFailedStep(AfterStepScope $event) { - if (4 === $event->getResult()) { - try { - $this->takeScreenshot($event); - } catch (\WebDriver\Exception $e) { - $this->logException($e); - } + // Check failure code + if ($event->getTestResult()->getResultCode() !== TestResult::FAILED) { + return; + } + try { + $this->takeScreenshot($event); + } catch (WebDriverException $e) { + $this->logException($e); } } @@ -249,24 +299,25 @@ JS; * Close modal dialog if test scenario fails on CMS page * * @AfterScenario + * @param AfterScenarioScope $event */ - public function closeModalDialog(ScenarioEvent $event) + public function closeModalDialog(AfterScenarioScope $event) { try { // Only for failed tests on CMS page - if (4 === $event->getResult()) { + if ($event->getTestResult()->getResultCode() === TestResult::FAILED) { $cmsElement = $this->getSession()->getPage()->find('css', '.cms'); if ($cmsElement) { try { // Navigate away triggered by reloading the page $this->getSession()->reload(); - $this->getSession()->getDriver()->getWebDriverSession()->accept_alert(); - } catch (\WebDriver\Exception $e) { + $this->getWebDriverSession()->accept_alert(); + } catch (WebDriverException $e) { // no-op, alert might not be present } } } - } catch (\WebDriver\Exception $e) { + } catch (WebDriverException $e) { $this->logException($e); } } @@ -275,8 +326,9 @@ JS; * Delete any created files and folders from assets directory * * @AfterScenario @assets + * @param AfterScenarioScope $event */ - public function cleanAssetsAfterScenario(ScenarioEvent $event) + public function cleanAssetsAfterScenario(AfterScenarioScope $event) { foreach (File::get() as $file) { $file->delete(); @@ -284,23 +336,30 @@ JS; Filesystem::removeFolder(ASSETS_PATH, true); } - public function takeScreenshot(StepEvent $event) + /** + * Take a nice screenshot + * + * @param StepScope $event + */ + public function takeScreenshot(StepScope $event) { + // Validate driver $driver = $this->getSession()->getDriver(); - // quit silently when unsupported if (!($driver instanceof Selenium2Driver)) { + file_put_contents('php://stdout', 'ScreenShots are only supported for Selenium2Driver: skipping'); return; } - $parent = $event->getLogicalParent(); - $feature = $parent->getFeature(); + $feature = $event->getFeature(); $step = $event->getStep(); $screenshotPath = null; + // Check paths are configured $path = $this->getMainContext()->getScreenshotPath(); if (!$path) { + file_put_contents('php://stdout', 'ScreenShots path not configured: skipping'); return; - } // quit silently when path is not set + } Filesystem::makeFolder($path); $path = realpath($path); @@ -325,22 +384,6 @@ JS; file_put_contents('php://stderr', sprintf('Saving screenshot into %s' . PHP_EOL, $path)); } - /** - * @Then /^I should be redirected to "([^"]+)"/ - */ - public function stepIShouldBeRedirectedTo($url) - { - if ($this->getMainContext()->canIntercept()) { - $client = $this->getSession()->getDriver()->getClient(); - $client->followRedirects(true); - $client->followRedirect(); - - $url = $this->getMainContext()->joinUrlParts($this->context['base_url'], $url); - - assertTrue($this->getMainContext()->isCurrentUrlSimilarTo($url), sprintf('Current URL is not %s', $url)); - } - } - /** * @Given /^the page can't be found/ */ @@ -357,6 +400,8 @@ JS; /** * @Given /^I wait (?:for )?([\d\.]+) second(?:s?)$/ + * + * @param float $secs */ public function stepIWaitFor($secs) { @@ -367,7 +412,7 @@ JS; * Find visible button with the given text. * Supports data-text-alternate property. * - * @param string $text + * @param string $title * @return NodeElement|null */ protected function findNamedButton($title) @@ -382,9 +427,10 @@ JS; ]; foreach ($searches as list($type, $arg)) { $buttons = $page->findAll($type, $arg); - foreach ($buttons as $el) { - if ($el->isVisible()) { - return $el; + /** @var NodeElement $button */ + foreach ($buttons as $button) { + if ($button->isVisible()) { + return $button; } } } @@ -396,6 +442,8 @@ JS; * Example: I should not see a "Delete" button * * @Given /^I should( not? |\s*)see (?:a|an|the) "([^"]*)" button$/ + * @param string $negative + * @param string $text */ public function iShouldSeeAButton($negative, $text) { @@ -409,6 +457,7 @@ JS; /** * @Given /^I press the "([^"]*)" button$/ + * @param string $text */ public function stepIPressTheButton($text) { @@ -423,6 +472,7 @@ JS; * Example2: I follow the "Remove current combo" link, confirming the dialog * * @Given /^I (?:press|follow) the "([^"]*)" (?:button|link), confirming the dialog$/ + * @param string $button */ public function stepIPressTheButtonConfirmingTheDialog($button) { @@ -435,6 +485,7 @@ JS; * Example: I follow the "Remove current combo" link, dismissing the dialog * * @Given /^I (?:press|follow) the "([^"]*)" (?:button|link), dismissing the dialog$/ + * @param string $button */ public function stepIPressTheButtonDismissingTheDialog($button) { @@ -444,6 +495,9 @@ JS; /** * @Given /^I (click|double click) "([^"]*)" in the "([^"]*)" element$/ + * @param string $clickType + * @param string $text + * @param string $selector */ public function iClickInTheElement($clickType, $text, $selector) { @@ -461,22 +515,29 @@ JS; } /** - * Needs to be in single command to avoid "unexpected alert open" errors in Selenium. - * Example: I click "Delete" in the ".actions" element, confirming the dialog - * - * @Given /^I (click|double click) "([^"]*)" in the "([^"]*)" element, confirming the dialog$/ - */ + * Needs to be in single command to avoid "unexpected alert open" errors in Selenium. + * Example: I click "Delete" in the ".actions" element, confirming the dialog + * + * @Given /^I (click|double click) "([^"]*)" in the "([^"]*)" element, confirming the dialog$/ + * @param string $clickType + * @param string $text + * @param string $selector + */ public function iClickInTheElementConfirmingTheDialog($clickType, $text, $selector) { $this->iClickInTheElement($clickType, $text, $selector); $this->iConfirmTheDialog(); } - /** - * Needs to be in single command to avoid "unexpected alert open" errors in Selenium. - * Example: I click "Delete" in the ".actions" element, dismissing the dialog - * - * @Given /^I (click|double click) "([^"]*)" in the "([^"]*)" element, dismissing the dialog$/ - */ + + /** + * Needs to be in single command to avoid "unexpected alert open" errors in Selenium. + * Example: I click "Delete" in the ".actions" element, dismissing the dialog + * + * @Given /^I (click|double click) "([^"]*)" in the "([^"]*)" element, dismissing the dialog$/ + * @param string $clickType + * @param string $text + * @param string $selector + */ public function iClickInTheElementDismissingTheDialog($clickType, $text, $selector) { $this->iClickInTheElement($clickType, $text, $selector); @@ -485,6 +546,7 @@ JS; /** * @Given /^I see the text "([^"]+)" in the alert$/ + * @param string $expected */ public function iSeeTheDialogText($expected) { @@ -497,13 +559,11 @@ JS; /** * @Given /^I type "([^"]*)" into the dialog$/ + * @param string $data */ public function iTypeIntoTheDialog($data) { - $data = array( - 'text' => $data, - ); - $this->getSession()->getDriver()->getWebDriverSession()->postAlert_text($data); + $this->getWebDriverSession()->postAlert_text([ 'text' => $data ]); } /** @@ -511,7 +571,7 @@ JS; */ public function iConfirmTheDialog() { - $this->getSession()->getDriver()->getWebDriverSession()->accept_alert(); + $this->getWebDriverSession()->accept_alert(); $this->handleAjaxTimeout(); } @@ -520,18 +580,36 @@ JS; */ public function iDismissTheDialog() { - $this->getSession()->getDriver()->getWebDriverSession()->dismiss_alert(); + $this->getWebDriverSession()->dismiss_alert(); $this->handleAjaxTimeout(); } + /** + * Get Selenium webdriver session. + * Note: Will fail if current driver isn't Selenium2Driver + * + * @return WebDriverSession + */ + protected function getWebDriverSession() + { + $driver = $this->getSession()->getDriver(); + if (! $driver instanceof Selenium2Driver) { + throw new \InvalidArgumentException("Not supported for non-selenium2 drivers"); + } + return $driver->getWebDriverSession(); + } + /** * @Given /^(?:|I )attach the file "(?P[^"]*)" to "(?P(?:[^"]|\\")*)" with HTML5$/ + * @param string $field + * @param string $path + * @return Call\Given */ public function iAttachTheFileTo($field, $path) { // Remove wrapped button styling to make input field accessible to Selenium $js = <<getSession()->executeScript($js); $this->getSession()->wait(1000); - return new Step\Given(sprintf('I attach the file "%s" to "%s"', $path, $field)); + return $this->getMainContext()->attachFileToField($field, $path); } /** * Select an individual input from within a group, matched by the top-most label. * * @Given /^I select "([^"]*)" from "([^"]*)" input group$/ + * @param string $value + * @param string $labelText */ public function iSelectFromInputGroup($value, $labelText) { $page = $this->getSession()->getPage(); $parent = null; + /** @var NodeElement $label */ foreach ($page->findAll('css', 'label') as $label) { if ($label->getText() == $labelText) { $parent = $label->getParent(); @@ -563,6 +644,7 @@ JS; throw new \InvalidArgumentException(sprintf('Input group with label "%s" cannot be found', $labelText)); } + /** @var NodeElement $option */ foreach ($parent->findAll('css', 'label') as $option) { if ($option->getText() == $value) { $input = null; @@ -608,6 +690,9 @@ JS; * Customize through {@link setTimeFormat()}. * * @Transform /^(?:(the|a)) time of (?.*)$/ + * @param string $prefix + * @param string $val + * @return false|string */ public function castRelativeToAbsoluteTime($prefix, $val) { @@ -627,6 +712,9 @@ JS; * the 12th of October 2013. Customize through {@link setDatetimeFormat()}. * * @Transform /^(?:(the|a)) datetime of (?.*)$/ + * @param string $prefix + * @param string $val + * @return false|string */ public function castRelativeToAbsoluteDatetime($prefix, $val) { @@ -646,6 +734,9 @@ JS; * the 12th of October 2013. Customize through {@link setDateFormat()}. * * @Transform /^(?:(the|a)) date of (?.*)$/ + * @param string $prefix + * @param string $val + * @return false|string */ public function castRelativeToAbsoluteDate($prefix, $val) { @@ -696,6 +787,9 @@ JS; * * @Then /^the "(?P(?:[^"]|\\")*)" (?P(?:(field|button))) should (?P(?:(not |)))be disabled/ * @Then /^the (?P(?:(field|button))) "(?P(?:[^"]|\\")*)" should (?P(?:(not |)))be disabled/ + * @param string $name + * @param string $type + * @param string $negate */ public function stepFieldShouldBeDisabled($name, $type, $negate) { @@ -704,7 +798,8 @@ JS; $element = $page->findField($name); } else { $element = $page->find('named', array( - 'button', $this->getSession()->getSelectorsHandler()->xpathLiteral($name) + 'button', + $this->getMainContext()->getXpathEscaper()->escapeLiteral($name) )); } @@ -725,6 +820,7 @@ JS; * * @Then /^the "(?P(?:[^"]|\\")*)" field should be enabled/ * @Then /^the field "(?P(?:[^"]|\\")*)" should be enabled/ + * @param string $field */ public function stepFieldShouldBeEnabled($field) { @@ -745,6 +841,9 @@ JS; * Example: Given I follow "Select" in the "My Login Form" region * * @Given /^I (?:follow|click) "(?P[^"]*)" in the "(?P[^"]*)" region$/ + * @param string $link + * @param string $region + * @throws \Exception */ public function iFollowInTheRegion($link, $region) { @@ -767,6 +866,10 @@ JS; * Example: Given I fill in "Hello" with "World" * * @Given /^I fill in "(?P[^"]*)" with "(?P[^"]*)" in the "(?P[^"]*)" region$/ + * @param string $field + * @param string $value + * @param string $region + * @throws \Exception */ public function iFillinTheRegion($field, $value, $region) { @@ -793,6 +896,10 @@ JS; * Example: Given I should not see "My Text" in the "My Login Form" region * * @Given /^I should (?P(?:(not |)))see "(?P[^"]*)" in the "(?P[^"]*)" region$/ + * @param string $negate + * @param string $text + * @param string $region + * @throws \Exception */ public function iSeeTextInRegion($negate, $text, $region) { @@ -833,19 +940,23 @@ JS; * Selects the specified radio button * * @Given /^I select the "([^"]*)" radio button$/ + * @param string $radioLabel */ public function iSelectTheRadioButton($radioLabel) { $session = $this->getSession(); - $radioButton = $session->getPage()->find('named', array( - 'radio', $this->getSession()->getSelectorsHandler()->xpathLiteral($radioLabel) - )); + $radioButton = $session->getPage()->find('named', [ + 'radio', + $this->getMainContext()->getXpathEscaper()->escapeLiteral($radioLabel) + ]); assertNotNull($radioButton); $session->getDriver()->click($radioButton->getXPath()); } /** * @Then /^the "([^"]*)" table should contain "([^"]*)"$/ + * @param string $selector + * @param string $text */ public function theTableShouldContain($selector, $text) { @@ -857,6 +968,8 @@ JS; /** * @Then /^the "([^"]*)" table should not contain "([^"]*)"$/ + * @param string $selector + * @param string $text */ public function theTableShouldNotContain($selector, $text) { @@ -868,6 +981,8 @@ JS; /** * @Given /^I click on "([^"]*)" in the "([^"]*)" table$/ + * @param string $text + * @param string $selector */ public function iClickOnInTheTable($text, $selector) { @@ -886,11 +1001,12 @@ JS; * - fieldset[data-name] table * - table caption * - * @return Behat\Mink\Element\NodeElement + * @param string $selector + * @return NodeElement */ protected function getTable($selector) { - $selector = $this->getSession()->getSelectorsHandler()->xpathLiteral($selector); + $selector = $this->getMainContext()->getXpathEscaper()->escapeLiteral($selector); $page = $this->getSession()->getPage(); $candidates = $page->findAll( 'xpath', @@ -914,6 +1030,7 @@ JS; assertTrue((bool)$candidates, 'Could not find any table elements'); $table = null; + /** @var NodeElement $candidate */ foreach ($candidates as $candidate) { if (!$table && $candidate->isVisible()) { $table = $candidate; @@ -929,6 +1046,10 @@ JS; * Checks the order of two texts. * Assumptions: the two texts appear in their conjunct parent element once * @Then /^I should see the text "(?P(?:[^"]|\\")*)" (before|after) the text "(?P(?:[^"]|\\")*)" in the "(?P[^"]*)" element$/ + * @param string $textBefore + * @param string $order + * @param string $textAfter + * @param string $element */ public function theTextBeforeAfter($textBefore, $order, $textAfter, $element) { @@ -950,17 +1071,19 @@ JS; } /** - * Wait until a certain amount of seconds till I see an element identified by a CSS selector. - * - * Example: Given I wait for 10 seconds until I see the ".css_element" element - * - * @Given /^I wait for (\d+) seconds until I see the "([^"]*)" element$/ - **/ + * Wait until a certain amount of seconds till I see an element identified by a CSS selector. + * + * Example: Given I wait for 10 seconds until I see the ".css_element" element + * + * @Given /^I wait for (\d+) seconds until I see the "([^"]*)" element$/ + * @param int $wait + * @param string $selector + */ public function iWaitXUntilISee($wait, $selector) { $page = $this->getSession()->getPage(); - $this->spin(function ($page) use ($page, $selector) { + $this->spin(function () use ($page, $selector) { $element = $page->find('css', $selector); if (empty($element)) { @@ -978,11 +1101,12 @@ JS; * Example: Given I wait until I see the "header .login-form" element * * @Given /^I wait until I see the "([^"]*)" element$/ + * @param string $selector */ public function iWaitUntilISee($selector) { $page = $this->getSession()->getPage(); - $this->spin(function ($page) use ($page, $selector) { + $this->spin(function () use ($page, $selector) { $element = $page->find('css', $selector); if (empty($element)) { return false; @@ -999,12 +1123,13 @@ JS; * Example: Given I wait until I see the text "Welcome back, John!" * * @Given /^I wait until I see the text "([^"]*)"$/ + * @param string $text */ public function iWaitUntilISeeText($text) { $page = $this->getSession()->getPage(); $session = $this->getSession(); - $this->spin(function ($page) use ($page, $session, $text) { + $this->spin(function () use ($page, $session, $text) { $element = $page->find( 'xpath', $session->getSelectorsHandler()->selectorToXpath("xpath", ".//*[contains(text(), '$text')]") @@ -1043,6 +1168,8 @@ JS; * Example: Given I scroll to the "My Date" field * * @Given /^I scroll to the "([^"]*)" (field|link|button)$/ + * @param string $locator + * @param string $type */ public function iScrollToField($locator, $type) { @@ -1066,6 +1193,7 @@ JS; * Example: Given I scroll to the ".css_element" element * * @Given /^I scroll to the "(?P(?:[^"]|\\")*)" element$/ + * @param string $locator */ public function iScrollToElement($locator) { @@ -1115,12 +1243,34 @@ JS; } - /** * We have to catch exceptions and log somehow else otherwise behat falls over + * + * @param Exception $exception */ - protected function logException($e) + protected function logException(Exception $exception) { - file_put_contents('php://stderr', 'Exception caught: '.$e); + file_put_contents('php://stderr', 'Exception caught: ' . $exception->getMessage()); + } + + /** + * Check if a step has a given tag + * + * @param StepScope $event + * @param string $tag + * @return bool + */ + 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; + } + return false; } } diff --git a/src/SilverStripe/BehatExtension/Context/EmailContext.php b/src/Context/EmailContext.php similarity index 87% rename from src/SilverStripe/BehatExtension/Context/EmailContext.php rename to src/Context/EmailContext.php index 30e8e61..d3e73c5 100644 --- a/src/SilverStripe/BehatExtension/Context/EmailContext.php +++ b/src/Context/EmailContext.php @@ -2,26 +2,22 @@ namespace SilverStripe\BehatExtension\Context; -use Behat\Behat\Context\BehatContext; -use Behat\Behat\Context\Step; -use Behat\Behat\Event\ScenarioEvent; +use Behat\Behat\Context\Context; +use Behat\Behat\Hook\Scope\BeforeScenarioScope; use Behat\Gherkin\Node\TableNode; use Behat\Mink\Session; use SilverStripe\BehatExtension\Utility\TestMailer; use SilverStripe\Control\Email\Email; -use SilverStripe\Core\Config\Config; +use SilverStripe\Control\Email\Mailer; use SilverStripe\Core\Injector\Injector; use Symfony\Component\DomCrawler\Crawler; -// PHPUnit -require_once BASE_PATH . '/vendor/phpunit/phpunit/src/Framework/Assert/Functions.php'; - /** * Context used to define steps related to email sending. */ -class EmailContext extends BehatContext +class EmailContext implements Context { - protected $context; + use MainContextAwareTrait; /** * @var TestMailer @@ -33,18 +29,6 @@ class EmailContext extends BehatContext */ protected $lastMatchedEmail; - /** - * Initializes context. - * Every scenario gets it's own context object. - * - * @param array $parameters context parameters (set them up through behat.yml) - */ - public function __construct(array $parameters) - { - // Initialize your context here - $this->context = $parameters; - } - /** * Get Mink session from MinkContext * @@ -58,18 +42,22 @@ class EmailContext extends BehatContext /** * @BeforeScenario + * @param BeforeScenarioScope $event */ - public function before(ScenarioEvent $event) + public function before(BeforeScenarioScope $event) { // Also set through the 'supportbehat' extension // to ensure its available both in CLI execution and the tested browser session $this->mailer = new TestMailer(); - Injector::inst()->registerService($this->mailer, 'SilverStripe\\Control\\Email\\Mailer'); + Injector::inst()->registerService($this->mailer, Mailer::class); Email::config()->update("send_all_emails_to", null); } /** * @Given /^there should (not |)be an email (to|from) "([^"]*)"$/ + * @param string $negate + * @param string $direction + * @param string $email */ public function thereIsAnEmailFromTo($negate, $direction, $email) { @@ -86,6 +74,10 @@ class EmailContext extends BehatContext /** * @Given /^there should (not |)be an email (to|from) "([^"]*)" titled "([^"]*)"$/ + * @param string $negate + * @param string $direction + * @param string $email + * @param string $subject */ public function thereIsAnEmailFromToTitled($negate, $direction, $email, $subject) { @@ -119,6 +111,8 @@ class EmailContext extends BehatContext * e.g. through 'Given there should be an email to "test@test.com"'. * * @Given /^the email should (not |)contain "([^"]*)"$/ + * @param string $negate + * @param string $content */ public function thereTheEmailContains($negate, $content) { @@ -148,6 +142,7 @@ class EmailContext extends BehatContext * e.g. through 'Given there should be an email to "test@test.com"'. * * @Given /^the email should contain plain text "([^"]*)"$/ + * @param string $content */ public function thereTheEmailContainsPlainText($content) { @@ -165,6 +160,9 @@ class EmailContext extends BehatContext /** * @When /^I click on the "([^"]*)" link in the email (to|from) "([^"]*)"$/ + * @param string $linkSelector + * @param string $direction + * @param string $email */ public function iGoToInTheEmailTo($linkSelector, $direction, $email) { @@ -179,11 +177,15 @@ class EmailContext extends BehatContext $link = $linkEl->attr('href'); assertNotNull($link); - return new Step\When(sprintf('I go to "%s"', $link)); + $this->getMainContext()->visit($link); } /** * @When /^I click on the "([^"]*)" link in the email (to|from) "([^"]*)" titled "([^"]*)"$/ + * @param string $linkSelector + * @param string $direction + * @param string $email + * @param string $title */ public function iGoToInTheEmailToTitled($linkSelector, $direction, $email, $title) { @@ -197,7 +199,7 @@ class EmailContext extends BehatContext assertNotNull($linkEl); $link = $linkEl->attr('href'); assertNotNull($link); - return new Step\When(sprintf('I go to "%s"', $link)); + $this->getMainContext()->visit($link); } /** @@ -205,6 +207,7 @@ class EmailContext extends BehatContext * e.g. through 'Given there should be an email to "test@test.com"'. * * @When /^I click on the "([^"]*)" link in the email"$/ + * @param string $linkSelector */ public function iGoToInTheEmail($linkSelector) { @@ -219,7 +222,7 @@ class EmailContext extends BehatContext $link = $linkEl->attr('href'); assertNotNull($link); - return new Step\When(sprintf('I go to "%s"', $link)); + $this->getMainContext()->visit($link); } /** @@ -228,7 +231,7 @@ class EmailContext extends BehatContext public function iClearAllEmails() { $this->lastMatchedEmail = null; - return $this->mailer->clearEmails(); + $this->mailer->clearEmails(); } /** @@ -237,6 +240,8 @@ class EmailContext extends BehatContext * | row2 | * Assumes an email has been identified by a previous step. * @Then /^the email should (not |)contain the following data:$/ + * @param string $negate + * @param TableNode $table */ public function theEmailContainFollowingData($negate, TableNode $table) { @@ -270,6 +275,8 @@ class EmailContext extends BehatContext /** * @Then /^there should (not |)be an email titled "([^"]*)"$/ + * @param string $negate + * @param string $subject */ public function thereIsAnEmailTitled($negate, $subject) { @@ -288,6 +295,8 @@ class EmailContext extends BehatContext /** * @Then /^the email should (not |)be sent from "([^"]*)"$/ + * @param string $negate + * @param string $from */ public function theEmailSentFrom($negate, $from) { @@ -305,6 +314,8 @@ class EmailContext extends BehatContext /** * @Then /^the email should (not |)be sent to "([^"]*)"$/ + * @param string $negate + * @param string $to */ public function theEmailSentTo($negate, $to) { @@ -325,6 +336,7 @@ class EmailContext extends BehatContext * e.g. http://localhost/Security/changepassword?m=199&title=reset * Example: When I click on the http link "changepassword" in the email * @When /^I click on the http link "([^"]*)" in the email$/ + * @param string $httpText */ public function iClickOnHttpLinkInEmail($httpText) { @@ -348,6 +360,6 @@ class EmailContext extends BehatContext } assertNotNull($href); - return new Step\When(sprintf('I go to "%s"', $href)); + $this->getMainContext()->visit($href); } } diff --git a/src/SilverStripe/BehatExtension/Context/FixtureContext.php b/src/Context/FixtureContext.php similarity index 78% rename from src/SilverStripe/BehatExtension/Context/FixtureContext.php rename to src/Context/FixtureContext.php index 7d2368b..a64fcd9 100644 --- a/src/SilverStripe/BehatExtension/Context/FixtureContext.php +++ b/src/Context/FixtureContext.php @@ -2,30 +2,36 @@ namespace SilverStripe\BehatExtension\Context; -use Behat\Behat\Context\BehatContext; -use Behat\Behat\Event\ScenarioEvent; +use Behat\Behat\Context\Context; +use Behat\Behat\Hook\Scope\AfterScenarioScope; +use Behat\Behat\Hook\Scope\BeforeScenarioScope; use Behat\Gherkin\Node\PyStringNode; use Behat\Gherkin\Node\TableNode; +use Exception; +use InvalidArgumentException; use SilverStripe\Assets\Folder; use SilverStripe\Assets\Storage\AssetStore; use SilverStripe\Core\ClassInfo; use SilverStripe\Core\Injector\Injector; +use SilverStripe\Dev\BehatFixtureFactory; +use SilverStripe\Dev\FixtureBlueprint; use SilverStripe\Dev\FixtureFactory; use SilverStripe\Dev\SapphireTest; use SilverStripe\Dev\YamlFixture; use SilverStripe\ORM\DB; use SilverStripe\ORM\DataObject; use SilverStripe\Versioned\Versioned; +use SilverStripe\Security\Group; +use SilverStripe\Security\Member; use SilverStripe\Security\Permission; -// PHPUnit -require_once BASE_PATH . '/vendor/phpunit/phpunit/src/Framework/Assert/Functions.php'; - /** * Context used to create fixtures in the SilverStripe ORM. */ -class FixtureContext extends BehatContext +class FixtureContext implements Context { + use MainContextAwareTrait; + protected $context; /** @@ -50,14 +56,16 @@ class FixtureContext extends BehatContext */ protected $createdAssets = array(); - public function __construct(array $parameters) + /** + * FixtureContext constructor. + * @param null $filesPath + */ + public function __construct($filesPath = null) { - $this->context = $parameters; - } - - public function getSession($name = null) - { - return $this->getMainContext()->getSession($name); + if (empty($filesPath)) { + throw new InvalidArgumentException("filesPath is required"); + } + $this->setFilesPath($filesPath); } /** @@ -66,14 +74,33 @@ class FixtureContext extends BehatContext public function getFixtureFactory() { if (!$this->fixtureFactory) { - $this->fixtureFactory = Injector::inst()->create( - 'SilverStripe\\Dev\\FixtureFactory', - 'FixtureContextFactory' - ); + $this->fixtureFactory = $this->scaffoldDefaultFixtureFactory(); } return $this->fixtureFactory; } + + /** + * Build default fixture factory + * + * @return FixtureFactory + */ + protected function scaffoldDefaultFixtureFactory() + { + $fixtureFactory = Injector::inst()->create(BehatFixtureFactory::class); + + // Register blueprints + /** @var FixtureBlueprint $blueprint */ + $blueprint = Injector::inst()->create(FixtureBlueprint::class, Member::class); + $blueprint->addCallback('beforeCreate', function ($identifier, &$data, &$fixtures) { + if (!isset($data['FirstName'])) { + $data['FirstName'] = $identifier; + } + }); + $fixtureFactory->define(Member::class, $blueprint); + return $fixtureFactory; + } + /** * @param FixtureFactory $factory */ @@ -100,12 +127,14 @@ class FixtureContext extends BehatContext /** * @BeforeScenario @database-defaults + * + * @param BeforeScenarioScope $event */ - public function beforeDatabaseDefaults(ScenarioEvent $event) + public function beforeDatabaseDefaults(BeforeScenarioScope $event) { SapphireTest::empty_temp_db(); DB::get_conn()->quiet(); - $dataClasses = ClassInfo::subclassesFor('SilverStripe\\ORM\\DataObject'); + $dataClasses = ClassInfo::subclassesFor(DataObject::class); array_shift($dataClasses); foreach ($dataClasses as $dataClass) { \singleton($dataClass)->requireDefaultRecords(); @@ -114,16 +143,18 @@ class FixtureContext extends BehatContext /** * @AfterScenario + * @param AfterScenarioScope $event */ - public function afterResetDatabase(ScenarioEvent $event) + public function afterResetDatabase(AfterScenarioScope $event) { SapphireTest::empty_temp_db(); } /** * @AfterScenario + * @param AfterScenarioScope $event */ - public function afterResetAssets(ScenarioEvent $event) + public function afterResetAssets(AfterScenarioScope $event) { $store = $this->getAssetStore(); if (is_array($this->createdAssets)) { @@ -137,18 +168,24 @@ class FixtureContext extends BehatContext * Example: Given a "page" "Page 1" * * @Given /^(?:an|a|the) "([^"]+)" "([^"]+)"$/ + * @param string $type + * @param string $id */ public function stepCreateRecord($type, $id) { $class = $this->convertTypeToClass($type); $fields = $this->prepareFixture($class, $id); - $this->fixtureFactory->createObject($class, $id, $fields); + $this->getFixtureFactory()->createObject($class, $id, $fields); } /** * Example: Given a "page" "Page 1" has the "content" "My content" * * @Given /^(?:an|a|the) "([^"]+)" "([^"]+)" has (?:an|a|the) "(.*)" "(.*)"$/ + * @param string $type + * @param string $id + * @param string $field + * @param string $value */ public function stepCreateRecordHasField($type, $id, $field, $value) { @@ -158,14 +195,14 @@ class FixtureContext extends BehatContext array($field => $value) ); // We should check if this fixture object already exists - if it does, we update it. If not, we create it - if ($existingFixture = $this->fixtureFactory->get($class, $id)) { + if ($existingFixture = $this->getFixtureFactory()->get($class, $id)) { // Merge existing data with new data, and create new object to replace existing object foreach ($fields as $k => $v) { $existingFixture->$k = $v; } $existingFixture->write(); } else { - $this->fixtureFactory->createObject($class, $id, $fields); + $this->getFixtureFactory()->createObject($class, $id, $fields); } } @@ -174,6 +211,9 @@ class FixtureContext extends BehatContext * Example: Given the "page" "Page 1" has "URL"="page-1" and "Content"="my page 1" * * @Given /^(?:an|a|the) "([^"]+)" "([^"]+)" (?:with|has) (".*)$/ + * @param string $type + * @param string $id + * @param string $data */ public function stepCreateRecordWithData($type, $id, $data) { @@ -189,14 +229,14 @@ class FixtureContext extends BehatContext ); $fields = $this->prepareFixture($class, $id, $fields); // We should check if this fixture object already exists - if it does, we update it. If not, we create it - if ($existingFixture = $this->fixtureFactory->get($class, $id)) { + if ($existingFixture = $this->getFixtureFactory()->get($class, $id)) { // Merge existing data with new data, and create new object to replace existing object foreach ($fields as $k => $v) { $existingFixture->$k = $v; } $existingFixture->write(); } else { - $this->fixtureFactory->createObject($class, $id, $fields); + $this->getFixtureFactory()->createObject($class, $id, $fields); } } @@ -207,6 +247,10 @@ class FixtureContext extends BehatContext * | My Boolean | bar | * * @Given /^(?:an|a|the) "([^"]+)" "([^"]+)" has the following data$/ + * @param string $type + * @param string $id + * @param string $null + * @param TableNode $fieldsTable */ public function stepCreateRecordWithTable($type, $id, $null, TableNode $fieldsTable) { @@ -232,6 +276,11 @@ class FixtureContext extends BehatContext * Note that this change is not published by default * * @Given /^(?:an|a|the) "([^"]+)" "([^"]+)" is a ([^\s]*) of (?:an|a|the) "([^"]+)" "([^"]+)"/ + * @param string $type + * @param string $id + * @param string $relation + * @param string $relationType + * @param string $relationId */ public function stepUpdateRecordRelation($type, $id, $relation, $relationType, $relationId) { @@ -265,7 +314,7 @@ class FixtureContext extends BehatContext // already written through $data above break; default: - throw new \InvalidArgumentException(sprintf( + throw new InvalidArgumentException(sprintf( 'Invalid relation "%s"', $relation )); @@ -278,6 +327,10 @@ class FixtureContext extends BehatContext * * @example I assign the "TaxonomyTerm" "For customers" to the "Page" "Page1" * @Given /^I assign (?:an|a|the) "([^"]+)" "([^"]+)" to (?:an|a|the) "([^"]+)" "([^"]+)"$/ + * @param string $type + * @param string $value + * @param string $relationType + * @param string $relationId */ public function stepIAssignObjToObj($type, $value, $relationType, $relationId) { @@ -291,6 +344,12 @@ class FixtureContext extends BehatContext * * @example I assign the "TaxonomyTerm" "For customers" to the "Page" "Page1" in the "Terms" relation * @Given /^I assign (?:an|a|the) "([^"]+)" "([^"]+)" to (?:an|a|the) "([^"]+)" "([^"]+)" in the "([^"]+)" relation$/ + * @param string $type + * @param string $value + * @param string $relationType + * @param string $relationId + * @param string $relationName + * @throws Exception */ public function stepIAssignObjToObjInTheRelation($type, $value, $relationType, $relationId, $relationName) { @@ -298,28 +357,28 @@ class FixtureContext extends BehatContext $relationClass = $this->convertTypeToClass($relationType); // Check if this fixture object already exists - if not, we create it - $relationObj = $this->fixtureFactory->get($relationClass, $relationId); + $relationObj = $this->getFixtureFactory()->get($relationClass, $relationId); if (!$relationObj) { - $relationObj = $this->fixtureFactory->createObject($relationClass, $relationId); + $relationObj = $this->getFixtureFactory()->createObject($relationClass, $relationId); } // Check if there is relationship defined in many_many (includes belongs_many_many) $manyField = null; $oneField = null; - if ($relationObj->many_many()) { - $manyField = array_search($class, $relationObj->many_many()); + if ($relationObj->manyMany()) { + $manyField = array_search($class, $relationObj->manyMany()); if ($manyField && strlen($relationName) > 0) { $manyField = $relationName; } } - if (empty($manyField) && $relationObj->has_many()) { - $manyField = array_search($class, $relationObj->has_many()); + if (empty($manyField) && $relationObj->hasMany(true)) { + $manyField = array_search($class, $relationObj->hasMany()); if ($manyField && strlen($relationName) > 0) { $manyField = $relationName; } } - if (empty($manyField) && $relationObj->has_one()) { - $oneField = array_search($class, $relationObj->has_one()); + if (empty($manyField) && $relationObj->hasOne()) { + $oneField = array_search($class, $relationObj->hasOne()); if ($oneField && strlen($relationName) > 0) { $oneField = $relationName; } @@ -341,7 +400,7 @@ class FixtureContext extends BehatContext // Check if the fixture object exists - if not, we create it $obj = DataObject::get($class)->filter($field, $value)->first(); if (!$obj) { - $obj = $this->fixtureFactory->createObject($class, $value); + $obj = $this->getFixtureFactory()->createObject($class, $value); } // If has_many or many_many, add this fixture object to the relation object // If has_one, set value to the joint field with this fixture object's ID @@ -360,14 +419,17 @@ class FixtureContext extends BehatContext * Example: Given the "page" "Page 1" is not published * * @Given /^(?:an|a|the) "([^"]+)" "([^"]+)" is ([^"]*)$/ + * @param string $type + * @param string $id + * @param string $state */ public function stepUpdateRecordState($type, $id, $state) { $class = $this->convertTypeToClass($type); /** @var DataObject|Versioned $obj */ - $obj = $this->fixtureFactory->get($class, $id); + $obj = $this->getFixtureFactory()->get($class, $id); if (!$obj) { - throw new \InvalidArgumentException(sprintf( + throw new InvalidArgumentException(sprintf( 'Can not find record "%s" with identifier "%s"', $type, $id @@ -390,7 +452,7 @@ class FixtureContext extends BehatContext $obj->delete(); break; default: - throw new \InvalidArgumentException(sprintf( + throw new InvalidArgumentException(sprintf( 'Invalid state: "%s"', $state )); @@ -407,10 +469,12 @@ class FixtureContext extends BehatContext * Email: member2@test.com * * @Given /^there are the following ([^\s]*) records$/ + * @param string $dataObject + * @param PyStringNode $string */ public function stepThereAreTheFollowingRecords($dataObject, PyStringNode $string) { - $yaml = array_merge(array($dataObject . ':'), $string->getLines()); + $yaml = array_merge(array($dataObject . ':'), $string->getStrings()); $yaml = implode("\n ", $yaml); // Save fixtures into database @@ -423,15 +487,19 @@ class FixtureContext extends BehatContext * Example: Given a "member" "Admin" belonging to "Admin Group" * * @Given /^(?:an|a|the) "member" "([^"]+)" belonging to "([^"]+)"$/ + * @param string $id + * @param string $groupId */ public function stepCreateMemberWithGroup($id, $groupId) { - $group = $this->fixtureFactory->get('SilverStripe\\Security\\Group', $groupId); + /** @var Group $group */ + $group = $this->getFixtureFactory()->get(Group::class, $groupId); if (!$group) { - $group = $this->fixtureFactory->createObject('SilverStripe\\Security\\Group', $groupId); + $group = $this->getFixtureFactory()->createObject(Group::class, $groupId); } - $member = $this->fixtureFactory->createObject('SilverStripe\\Security\\Member', $id); + /** @var Member $member */ + $member = $this->getFixtureFactory()->createObject(Member::class, $id); $member->Groups()->add($group); } @@ -439,26 +507,30 @@ class FixtureContext extends BehatContext * Example: Given a "member" "Admin" belonging to "Admin Group" with "Email"="test@test.com" * * @Given /^(?:an|a|the) "member" "([^"]+)" belonging to "([^"]+)" with (.*)$/ + * @param string $id + * @param string $groupId + * @param string $data */ public function stepCreateMemberWithGroupAndData($id, $groupId, $data) { - $class = 'SilverStripe\\Security\\Member'; preg_match_all( '/"(?[^"]+)"\s*=\s*"(?[^"]+)"/', $data, $matches ); $fields = $this->convertFields( - $class, + Member::class, array_combine($matches['key'], $matches['value']) ); - $group = $this->fixtureFactory->get('SilverStripe\\Security\\Group', $groupId); + /** @var Group $group */ + $group = $this->getFixtureFactory()->get(Group::class, $groupId); if (!$group) { - $group = $this->fixtureFactory->createObject('SilverStripe\\Security\\Group', $groupId); + $group = $this->getFixtureFactory()->createObject(Group::class, $groupId); } - $member = $this->fixtureFactory->createObject($class, $id, $fields); + /** @var Member $member */ + $member = $this->getFixtureFactory()->createObject(Member::class, $id, $fields); $member->Groups()->add($group); } @@ -466,6 +538,8 @@ class FixtureContext extends BehatContext * Example: Given a "group" "Admin" with permissions "Access to 'Pages' section" and "Access to 'Files' section" * * @Given /^(?:an|a|the) "group" "([^"]+)" (?:with|has) permissions (.*)$/ + * @param string $id + * @param string $permissionStr */ public function stepCreateGroupWithPermissions($id, $permissionStr) { @@ -474,9 +548,9 @@ class FixtureContext extends BehatContext $permissions = $matches[1]; $codes = Permission::get_codes(false); - $group = $this->fixtureFactory->get('SilverStripe\\Security\\Group', $id); + $group = $this->getFixtureFactory()->get(Group::class, $id); if (!$group) { - $group = $this->fixtureFactory->createObject('SilverStripe\\Security\\Group', $id); + $group = $this->getFixtureFactory()->createObject(Group::class, $id); } foreach ($permissions as $permission) { @@ -490,7 +564,7 @@ class FixtureContext extends BehatContext } } if (!$found) { - throw new \InvalidArgumentException(sprintf( + throw new InvalidArgumentException(sprintf( 'No permission found for "%s"', $permission )); @@ -504,22 +578,25 @@ class FixtureContext extends BehatContext * Example: Given I go to the "page" "My Page" * * @Given /^I go to (?:an|a|the) "([^"]+)" "([^"]+)"/ + * @param string $type + * @param string $id */ public function stepGoToNamedRecord($type, $id) { $class = $this->convertTypeToClass($type); - $record = $this->fixtureFactory->get($class, $id); + $record = $this->getFixtureFactory()->get($class, $id); if (!$record) { - throw new \InvalidArgumentException(sprintf( + throw new InvalidArgumentException(sprintf( 'Cannot resolve reference "%s", no matching fixture found', $id )); } if (!$record->hasMethod('RelativeLink')) { - throw new \InvalidArgumentException('URL for record cannot be determined, missing RelativeLink() method'); + throw new InvalidArgumentException('URL for record cannot be determined, missing RelativeLink() method'); } + $link = call_user_func([$record, 'RelativeLink']); - $this->getSession()->visit($this->getMainContext()->locatePath($record->RelativeLink())); + $this->getMainContext()->getSession()->visit($this->getMainContext()->locatePath($link)); } @@ -528,6 +605,8 @@ class FixtureContext extends BehatContext * Example: There should be a file "assets/Uploads/test.jpg" * * @Then /^there should be a ((file|folder) )"([^"]*)"/ + * @param string $type + * @param string $path */ public function stepThereShouldBeAFileOrFolder($type, $path) { @@ -540,6 +619,8 @@ class FixtureContext extends BehatContext * Example: there should be a filename "Uploads/test.jpg" with hash "59de0c841f" * * @Then /^there should be a filename "([^"]*)" with hash "([a-fA-Z0-9]+)"/ + * @param string $filename + * @param string $hash */ public function stepThereShouldBeAFileWithTuple($filename, $hash) { @@ -552,14 +633,16 @@ class FixtureContext extends BehatContext * with the notation "=>.". Example: "=>Page.My Page". * * @Transform /^([^"]+)$/ + * @param string $string + * @return mixed */ public function lookupFixtureReference($string) { if (preg_match('/^=>/', $string)) { list($className, $identifier) = explode('.', preg_replace('/^=>/', '', $string), 2); - $id = $this->fixtureFactory->getId($className, $identifier); + $id = $this->getFixtureFactory()->getId($className, $identifier); if (!$id) { - throw new \InvalidArgumentException(sprintf( + throw new InvalidArgumentException(sprintf( 'Cannot resolve reference "%s", no matching fixture found', $string )); @@ -572,12 +655,16 @@ class FixtureContext extends BehatContext /** * @Given /^(?:an|a|the) "([^"]*)" "([^"]*)" was (created|last edited) "([^"]*)"$/ + * @param string $type + * @param string $id + * @param string $mod + * @param string $time */ public function aRecordWasLastEditedRelative($type, $id, $mod, $time) { $class = $this->convertTypeToClass($type); $fields = $this->prepareFixture($class, $id); - $record = $this->fixtureFactory->createObject($class, $id, $fields); + $record = $this->getFixtureFactory()->createObject($class, $id, $fields); $date = date("Y-m-d H:i:s", strtotime($time)); $table = $record->baseTable(); $field = ($mod == 'created') ? 'Created' : 'LastEdited'; @@ -628,7 +715,7 @@ class FixtureContext extends BehatContext } else { // Check file exists if (!file_exists($sourcePath)) { - throw new \InvalidArgumentException(sprintf( + throw new InvalidArgumentException(sprintf( 'Source file for "%s" cannot be found in "%s"', $relativeTargetPath, $sourcePath @@ -707,7 +794,7 @@ class FixtureContext extends BehatContext } } - throw new \InvalidArgumentException(sprintf( + throw new InvalidArgumentException(sprintf( 'Class "%s" does not exist, or is not a subclass of DataObjet', $class )); diff --git a/src/SilverStripe/BehatExtension/Context/Initializer/SilverStripeAwareInitializer.php b/src/Context/Initializer/SilverStripeAwareInitializer.php similarity index 87% rename from src/SilverStripe/BehatExtension/Context/Initializer/SilverStripeAwareInitializer.php rename to src/Context/Initializer/SilverStripeAwareInitializer.php index 5afbfe1..0dd4ac0 100644 --- a/src/SilverStripe/BehatExtension/Context/Initializer/SilverStripeAwareInitializer.php +++ b/src/Context/Initializer/SilverStripeAwareInitializer.php @@ -2,10 +2,9 @@ namespace SilverStripe\BehatExtension\Context\Initializer; -use Behat\Behat\Context\Initializer\InitializerInterface; -use Behat\Behat\Context\ContextInterface; -use SilverStripe\BehatExtension\Context\SilverStripeAwareContextInterface; -use SilverStripe\Core\Injector\Injector; +use Behat\Behat\Context\Initializer\ContextInitializer; +use Behat\Behat\Context\Context; +use SilverStripe\BehatExtension\Context\SilverStripeAwareContext; use SilverStripe\Dev\SapphireTest; use SilverStripe\TestSession\TestSessionEnvironment; @@ -24,7 +23,7 @@ use SilverStripe\TestSession\TestSessionEnvironment; * * @author Michał Ochman */ -class SilverStripeAwareInitializer implements InitializerInterface +class SilverStripeAwareInitializer implements ContextInitializer { private $databaseName; @@ -59,12 +58,12 @@ class SilverStripeAwareInitializer implements InitializerInterface */ protected $testSessionEnvironment; + protected $regionMap; + /** * Initializes initializer. - * - * @param string $frameworkPath */ - public function __construct($frameworkPath) + public function __construct() { file_put_contents('php://stdout', 'Bootstrapping' . PHP_EOL); @@ -104,22 +103,24 @@ class SilverStripeAwareInitializer implements InitializerInterface /** * Checks if initializer supports provided context. * - * @param ContextInterface $context - * + * @param Context $context * @return Boolean */ - public function supports(ContextInterface $context) + public function supports(Context $context) { - return $context instanceof SilverStripeAwareContextInterface; + return $context instanceof SilverStripeAwareContext; } /** * Initializes provided context. * - * @param ContextInterface $context + * @param Context $context */ - public function initialize(ContextInterface $context) + public function initializeContext(Context $context) { + if (! $context instanceof SilverStripeAwareContext) { + return; + } $context->setDatabase($this->databaseName); $context->setAjaxSteps($this->ajaxSteps); $context->setAjaxTimeout($this->ajaxTimeout); diff --git a/src/Context/LoginContext.php b/src/Context/LoginContext.php new file mode 100644 index 0000000..235bdfa --- /dev/null +++ b/src/Context/LoginContext.php @@ -0,0 +1,181 @@ +getMainContext(); + $adminUrl = $c->joinUrlParts($c->getBaseUrl(), $c->getAdminUrl()); + $loginUrl = $c->joinUrlParts($c->getBaseUrl(), $c->getLoginUrl()); + + $this->getMainContext()->getSession()->visit($adminUrl); + + if (0 == strpos($this->getMainContext()->getSession()->getCurrentUrl(), $loginUrl)) { + $this->stepILogInWith('admin', 'password'); + assertStringStartsWith($adminUrl, $this->getMainContext()->getSession()->getCurrentUrl()); + } + } + + /** + * Creates a member in a group with the correct permissions. + * Example: Given I am logged in with "ADMIN" permissions + * + * @Given /^I am logged in with "([^"]*)" permissions$/ + * @param string $permCode + */ + public function iAmLoggedInWithPermissions($permCode) + { + $email = "{$permCode}@example.org"; + $password = 'Secret!123'; + $this->generateMemberWithPermission($email, $password, $permCode); + $this->stepILogInWith($email, $password); + } + + /** + * @Given /^I am not logged in$/ + */ + public function stepIAmNotLoggedIn() + { + $c = $this->getMainContext(); + $this->getMainContext()->getSession()->visit($c->joinUrlParts($c->getBaseUrl(), 'Security/logout')); + } + + /** + * @When /^I log in with "(?[^"]*)" and "(?[^"]*)"$/ + * @param string $email + * @param string $password + */ + public function stepILogInWith($email, $password) + { + $c = $this->getMainContext(); + $loginUrl = $c->joinUrlParts($c->getBaseUrl(), $c->getLoginUrl()); + $this->getMainContext()->getSession()->visit($loginUrl); + $page = $this->getMainContext()->getSession()->getPage(); + $forms = $page->findAll('xpath', '//form[contains(@action, "Security/LoginForm")]'); + assertNotNull($forms, 'Login form not found'); + + // Try to find visible forms again on login page. + $visibleForm = null; + /** @var NodeElement $form */ + foreach ($forms as $form) { + if ($form->isVisible() && $form->find('css', '[name=Email]')) { + $visibleForm = $form; + } + } + + assertNotNull($visibleForm, 'Could not find login form'); + + $emailField = $visibleForm->find('css', '[name=Email]'); + $passwordField = $visibleForm->find('css', '[name=Password]'); + $submitButton = $visibleForm->find('css', '[type=submit]'); + $securityID = $visibleForm->find('css', '[name=SecurityID]'); + + assertNotNull($emailField, 'Email field on login form not found'); + assertNotNull($passwordField, 'Password field on login form not found'); + assertNotNull($submitButton, 'Submit button on login form not found'); + assertNotNull($securityID, 'CSRF token not found'); + + $emailField->setValue($email); + $passwordField->setValue($password); + $submitButton->press(); + } + + /** + * @Given /^I should see a log-in form$/ + */ + public function stepIShouldSeeALogInForm() + { + $page = $this->getMainContext()->getSession()->getPage(); + $loginForm = $page->find('css', '#MemberLoginForm_LoginForm'); + assertNotNull($loginForm, 'I should see a log-in form'); + } + + /** + * @Then /^I will see a "([^"]*)" log-in message$/ + * @param string $type + */ + public function stepIWillSeeALogInMessage($type) + { + $page = $this->getMainContext()->getSession()->getPage(); + $message = $page->find('css', sprintf('.message.%s', $type)); + assertNotNull($message, sprintf('%s message not found.', $type)); + } + + /** + * @Then /^the password for "([^"]*)" should be "([^"]*)"$/ + * @skipUpgrade + * @param string $id + * @param string $password + */ + public function stepPasswordForEmailShouldBe($id, $password) + { + /** @var Member $member */ + $member = Member::get()->filter('Email', $id)->First(); + assertNotNull($member); + assertTrue($member->checkPassword($password)->isValid()); + } + + /** + * Get or generate a member with the given permission code + * + * @param string $email + * @param string $password + * @param string $permCode + * @return Member + */ + protected function generateMemberWithPermission($email, $password, $permCode) + { + // Get or create group + $group = Group::get()->filter('Title', "$permCode group")->first(); + if (!$group) { + $group = Group::create(); + } + + $group->Title = "$permCode group"; + $group->write(); + + // Get or create permission + $permission = Permission::create(); + $permission->Code = $permCode; + $permission->write(); + $group->Permissions()->add($permission); + + // Get or create member + $member = Member::get()->filter('Email', $email)->first(); + if (!$member) { + $member = Member::create(); + } + + // make sure any validation for password is skipped, since we're not testing complexity here + $validator = Member::password_validator(); + Member::set_password_validator(null); + $member->FirstName = $permCode; + $member->Surname = "User"; + $member->Email = $email; + $member->PasswordEncryption = "none"; + $member->changePassword($password); + $member->write(); + $group->Members()->add($member); + Member::set_password_validator($validator); + + return $member; + } +} diff --git a/src/Context/MainContextAwareTrait.php b/src/Context/MainContextAwareTrait.php new file mode 100644 index 0000000..6f8c992 --- /dev/null +++ b/src/Context/MainContextAwareTrait.php @@ -0,0 +1,63 @@ +mainContext; + } + + /** + * @param SilverStripeContext $mainContext + * @return $this + */ + public function setMainContext($mainContext) + { + $this->mainContext = $mainContext; + return $this; + } + + /** + * Helper method to detect the main context + * + * @BeforeScenario + * @param BeforeScenarioScope $scope + */ + public function detectMainContext(BeforeScenarioScope $scope) + { + $environment = $scope->getEnvironment(); + if (! $environment instanceof InitializedContextEnvironment) { + throw new \LogicException("No context available for this environment"); + } + + $contexts = $environment->getContexts(); + foreach ($contexts as $context) { + if ($context instanceof SilverStripeContext) { + $this->setMainContext($context); + return; + } + } + + throw new \LogicException("No SilverStripeContext is configured"); + } +} diff --git a/src/Context/RetryableContextTrait.php b/src/Context/RetryableContextTrait.php new file mode 100644 index 0000000..79656cd --- /dev/null +++ b/src/Context/RetryableContextTrait.php @@ -0,0 +1,25 @@ += 0); + return null; + } +} diff --git a/src/Context/SilverStripeAwareContext.php b/src/Context/SilverStripeAwareContext.php new file mode 100644 index 0000000..564b01c --- /dev/null +++ b/src/Context/SilverStripeAwareContext.php @@ -0,0 +1,71 @@ + + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +use Behat\MinkExtension\Context\MinkAwareContext; + +/** + * SilverStripe aware interface for contexts. + * + * @author Michał Ochman + */ +interface SilverStripeAwareContext extends MinkAwareContext +{ + /** + * Sets SilverStripe instance. + * + * @param string $databaseName Temp database name + */ + public function setDatabase($databaseName); + + /** + * Marks steps as AJAX steps for special treatment + * + * @param array $ajaxSteps Array of step name parts to match + */ + public function setAjaxSteps($ajaxSteps); + + /** + * Set timeout in millisceonds + * + * @param int $ajaxTimeout + */ + public function setAjaxTimeout($ajaxTimeout); + + /** + * Set admin url + * + * @param string $adminUrl + */ + public function setAdminUrl($adminUrl); + + /** + * Set login url + * + * @param string $loginUrl + */ + public function setLoginUrl($loginUrl); + + /** + * Set path to screenshots dir + * + * @param string $screenshotPath + */ + public function setScreenshotPath($screenshotPath); + + /** + * I have no idea + * + * @param $regionMap + */ + public function setRegionMap($regionMap); +} diff --git a/src/SilverStripe/BehatExtension/Context/SilverStripeContext.php b/src/Context/SilverStripeContext.php similarity index 79% rename from src/SilverStripe/BehatExtension/Context/SilverStripeContext.php rename to src/Context/SilverStripeContext.php index 8057cb3..84a7bca 100644 --- a/src/SilverStripe/BehatExtension/Context/SilverStripeContext.php +++ b/src/Context/SilverStripeContext.php @@ -2,29 +2,32 @@ namespace SilverStripe\BehatExtension\Context; -use Behat\Behat\Context\Step; -use Behat\Behat\Event\ScenarioEvent; +use Behat\Behat\Hook\Scope\BeforeScenarioScope; +use Behat\Mink\Element\NodeElement; +use Behat\Mink\Selector\Xpath\Escaper; use Behat\MinkExtension\Context\MinkContext; -use Behat\Mink\Driver\GoutteDriver; use Behat\Mink\Driver\Selenium2Driver; use Behat\Mink\Exception\UnsupportedDriverActionException; use Behat\Mink\Exception\ElementNotFoundException; use InvalidArgumentException; -use SilverStripe\BehatExtension\Context\SilverStripeAwareContextInterface; -use Symfony\Component\Yaml\Yaml; +use SilverStripe\CMS\Model\SiteTree; +use SilverStripe\Core\ClassInfo; +use SilverStripe\Core\Resettable; +use SilverStripe\ORM\DataObject; use SilverStripe\TestSession\TestSessionEnvironment; - -// Mink etc. -require_once 'vendor/autoload.php'; - -require_once BASE_PATH . '/vendor/phpunit/phpunit/src/Framework/Assert/Functions.php'; +use Symfony\Component\CssSelector\Exception\SyntaxErrorException; /** * SilverStripeContext * * Generic context wrapper used as a base for Behat FeatureContext. + * + * The default context for each module should extend this and be named `FeatureContext` + * under the standard module namespace. + * + * @link http://behat.org/en/latest/user_guide/context.html */ -class SilverStripeContext extends MinkContext implements SilverStripeAwareContextInterface +abstract class SilverStripeContext extends MinkContext implements SilverStripeAwareContext { protected $databaseName; @@ -32,7 +35,7 @@ class SilverStripeContext extends MinkContext implements SilverStripeAwareContex * @var array Partial string match for step names * that are considered to trigger Ajax request in the CMS, * and hence need special timeout handling. - * @see \SilverStripe\BehatExtension\Context\BasicContext->handleAjaxBeforeStep(). + * @see \SilverStripe\BehatExtension\Context\BasicContextAwareTrait->handleAjaxBeforeStep(). */ protected $ajaxSteps; @@ -58,10 +61,19 @@ class SilverStripeContext extends MinkContext implements SilverStripeAwareContex */ protected $screenshotPath; - protected $context; - + /** + * @var TestSessionEnvironment + */ protected $testSessionEnvironment; + protected $regionMap; + + /** + * XPath escaper + * + * @var Escaper + */ + protected $xpathEscaper; /** * Initializes context. @@ -69,11 +81,27 @@ class SilverStripeContext extends MinkContext implements SilverStripeAwareContex * * @param array $parameters context parameters (set them up through behat.yml) */ - public function __construct(array $parameters) + public function __construct(array $parameters = null) { + if (!preg_match('/\\FeatureContext$/', get_class($this))) { + throw new InvalidArgumentException( + 'Subclasses of SilverStripeContext must be named FeatureContext. Found "' . get_class($this) . '""' + ); + } + // Initialize your context here - $this->context = $parameters; - $this->testSessionEnvironment = new TestSessionEnvironment(); + $this->xpathEscaper = new Escaper(); + $this->testSessionEnvironment = TestSessionEnvironment::singleton(); + } + + /** + * Get xpath escaper + * + * @return Escaper + */ + public function getXpathEscaper() + { + return $this->xpathEscaper; } public function setDatabase($databaseName) @@ -144,12 +172,13 @@ class SilverStripeContext extends MinkContext implements SilverStripeAwareContex } /** - * Returns MinkElement based off region defined in .yml file. + * Returns NodeElement based off region defined in .yml file. * Also supports direct CSS selectors and regions identified by a "data-title" attribute. * When using the "data-title" attribute, ensure not to include double quotes. * * @param string $region Region name or CSS selector - * @return MinkElement + * @return NodeElement + * @throws ElementNotFoundException */ public function getRegionObj($region) { @@ -158,12 +187,12 @@ class SilverStripeContext extends MinkContext implements SilverStripeAwareContex $regionObj = $this->getSession()->getPage()->find( 'css', // Escape CSS selector - (false !== strpos($region, "'")) ? str_replace("'", "\'", $region) : $region + (false !== strpos($region, "'")) ? str_replace("'", "\\'", $region) : $region ); if ($regionObj) { return $regionObj; } - } catch (\Symfony\Component\CssSelector\Exception\SyntaxErrorException $e) { + } catch (SyntaxErrorException $e) { // fall through to next case } @@ -189,7 +218,7 @@ class SilverStripeContext extends MinkContext implements SilverStripeAwareContex } $regionObj = $this->getSession()->getPage()->find('css', $region); if (!$regionObj) { - throw new ElementNotFoundException("Cannot find the specified region on the page"); + throw new ElementNotFoundException($this->getSession(), "Cannot find the specified region on the page"); } return $regionObj; @@ -197,8 +226,9 @@ class SilverStripeContext extends MinkContext implements SilverStripeAwareContex /** * @BeforeScenario + * @param BeforeScenarioScope $event */ - public function before(ScenarioEvent $event) + public function before(BeforeScenarioScope $event) { if (!isset($this->databaseName)) { throw new \LogicException( @@ -231,6 +261,14 @@ class SilverStripeContext extends MinkContext implements SilverStripeAwareContex } else { $this->getSession()->resizeWindow(1024, 768); } + + // Reset everything + foreach (ClassInfo::implementorsOf(Resettable::class) as $class) { + $class::reset(); + } + DataObject::flush_and_destroy_cache(); + DataObject::reset(); + SiteTree::reset(); } /** @@ -319,14 +357,14 @@ class SilverStripeContext extends MinkContext implements SilverStripeAwareContex * Forward slash usages are normalised to one between parts. * This method takes variable number of parameters. * - * @param $... + * @param string $part,... * @return string - * @throws \InvalidArgumentException + * @throws InvalidArgumentException */ - public function joinUrlParts() + public function joinUrlParts($part = null) { if (0 === func_num_args()) { - throw new \InvalidArgumentException('Need at least one argument'); + throw new InvalidArgumentException('Need at least one argument'); } $parts = func_get_args(); @@ -341,44 +379,35 @@ class SilverStripeContext extends MinkContext implements SilverStripeAwareContex public function canIntercept() { $driver = $this->getSession()->getDriver(); - if ($driver instanceof GoutteDriver) { - return true; - } else { - if ($driver instanceof Selenium2Driver) { - return false; - } + if ($driver instanceof Selenium2Driver) { + return false; } - throw new UnsupportedDriverActionException('You need to tag the scenario with "@mink:goutte" or - "@mink:symfony". Intercepting the redirections is not supported by %s', $driver); - } - - /** - * @Given /^(.*) without redirection$/ - */ - public function theRedirectionsAreIntercepted($step) - { - if ($this->canIntercept()) { - $this->getSession()->getDriver()->getClient()->followRedirects(false); - } - - return new Step\Given($step); + throw new UnsupportedDriverActionException( + 'You need to tag the scenario with "@mink:symfony". Intercepting the redirections is not supported by %s', + get_class($driver) + ); } /** * Fills in form field with specified id|name|label|value. * Overwritten to select the first *visible* element, see https://github.com/Behat/Mink/issues/311 + * + * @param string $field + * @param string $value + * @throws ElementNotFoundException */ public function fillField($field, $value) { $value = $this->fixStepArgument($value); - $fields = $this->getSession()->getPage()->findAll('named', array( - 'field', $this->getSession()->getSelectorsHandler()->xpathLiteral($field) + $nodes = $this->getSession()->getPage()->findAll('named', array( + 'field', $this->getXpathEscaper()->escapeLiteral($field) )); - if ($fields) { - foreach ($fields as $f) { - if ($f->isVisible()) { - $f->setValue($value); + if ($nodes) { + /** @var NodeElement $node */ + foreach ($nodes as $node) { + if ($node->isVisible()) { + $node->setValue($value); return; } } @@ -394,17 +423,21 @@ class SilverStripeContext extends MinkContext implements SilverStripeAwareContex /** * Overwritten to click the first *visable* link the DOM. + * + * @param string $link + * @throws ElementNotFoundException */ public function clickLink($link) { $link = $this->fixStepArgument($link); - $links = $this->getSession()->getPage()->findAll('named', array( - 'link', $this->getSession()->getSelectorsHandler()->xpathLiteral($link) + $nodes = $this->getSession()->getPage()->findAll('named', array( + 'link', $this->getXpathEscaper()->escapeLiteral($link) )); - if ($links) { - foreach ($links as $l) { - if ($l->isVisible()) { - $l->click(); + if ($nodes) { + /** @var NodeElement $node */ + foreach ($nodes as $node) { + if ($node->isVisible()) { + $node->click(); return; } } @@ -424,6 +457,7 @@ class SilverStripeContext extends MinkContext implements SilverStripeAwareContex * Example: Given the current date is "2009-10-31" * * @Given /^the current date is "([^"]*)"$/ + * @param string $date */ public function givenTheCurrentDateIs($date) { @@ -448,6 +482,7 @@ class SilverStripeContext extends MinkContext implements SilverStripeAwareContex * Example: Given the current time is "20:31:50" * * @Given /^the current time is "([^"]*)"$/ + * @param string $time */ public function givenTheCurrentTimeIs($time) { @@ -469,6 +504,8 @@ class SilverStripeContext extends MinkContext implements SilverStripeAwareContex * Selects option in select field with specified id|name|label|value. * * @override /^(?:|I )select "(?P