Compare commits

...

111 Commits

Author SHA1 Message Date
Guy Sartorelli 5c69f38876
Merge pull request #269 from creative-commoners/pulls/4/behat-rerun
[Backport] Rerun failed features in ci
2024-04-15 16:35:39 +12:00
Steve Boyd 1452c35e08 [Backport] Rerun failed features in ci 2024-04-15 14:30:19 +12:00
github-actions 6c9692a4b6 Merge branch '4.11' into 4 2023-11-18 14:23:17 +00:00
Scott Sutherland d40fa9848f
remove FacebookWebDriver check as Selenium2Driver has functionality now (#256) 2023-11-14 16:56:53 +13:00
github-actions 0d3e4a91cb Merge branch '4.11' into 4 2023-09-18 02:25:29 +00:00
Sabina Talipova 6c962ef294
Merge pull request #250 from creative-commoners/pulls/4.11/extensions
NEW Assert element attributes
2023-09-14 19:06:42 +12:00
Steve Boyd 5ea2f157b0 NEW Assert element attributes 2023-09-12 13:03:49 +12:00
Guy Sartorelli 5c4a04864f
ENH Migrate image selection logic from asset-admin (#248) 2023-09-07 10:23:44 +12:00
Steve Boyd 9669e6b671 Merge branch '4.11' into 4 2023-07-06 16:44:14 +12:00
Guy Sartorelli e2c64133e5
Merge pull request #247 from creative-commoners/pulls/4.11/installer
MNT Remove composer_require_extra
2023-07-05 16:45:53 +12:00
Steve Boyd d1a7b36746 MNT Remove composer_require_extra 2023-07-05 11:53:49 +12:00
Maxime Rainville 1f5a17283e
Merge pull request #246 from creative-commoners/pulls/5/fix-typo-in-readme
DOC Fix typo in README to use correct prop for URLSegment
2023-06-23 09:24:42 +12:00
Maime Rainville 16d6814255 DOC Fix typo in README to use correct prop for URLSegment 2023-06-22 17:11:05 +12:00
Steve Boyd fc6b47b821 Merge branch '4.11' into 4 2023-06-01 13:44:21 +12:00
Maxime Rainville dd2146fd65
BUG Explicitly name all parameters on theTextBeforeAfter (#242) 2023-06-01 13:42:47 +12:00
Sabina Talipova b3de55e205
Merge pull request #226 from creative-commoners/pulls/4.10/fix-stepCreateRecordWithTable
BUG Correct method parameters on stepCreateRecordWithTable
2023-05-31 15:09:33 +12:00
Steve Boyd aa26c912ce BUG Correct method parameters on stepCreateRecordWithTable
Co-authored-by: Steve Boyd <emteknetnz@gmail.com>
2023-05-23 11:33:59 +12:00
Maxime Rainville 3c1896be68
Merge pull request #239 from creative-commoners/pulls/4/dispatch-ci
MNT Use gha-dispatch-ci
2023-03-23 12:05:01 +13:00
Steve Boyd 6e9582b0ea MNT Use gha-dispatch-ci 2023-03-21 12:07:11 +13:00
Sabina Talipova 2c425e7b8c
Merge pull request #235 from creative-commoners/pulls/4/stop-using-depr
API Stop using deprecated API
2022-12-05 16:43:04 +13:00
Steve Boyd 5c79d79f78 API Stop using deprecated API 2022-11-28 17:35:55 +13:00
Sabina Talipova 7c1762b36f
Merge pull request #233 from creative-commoners/pulls/4/stop-depr
API Stop using deprecated API
2022-11-11 12:14:25 +13:00
Steve Boyd 4dcea617d8 API Stop using deprecated API 2022-11-03 14:13:44 +13:00
Guy Sartorelli 276c60c75e
Merge pull request #228 from creative-commoners/pulls/4/deprecated
API Update deprecations
2022-10-25 12:26:00 +13:00
Steve Boyd 5c6b9756fb API Update deprecations 2022-10-18 14:49:22 +13:00
Steve Boyd 6a0185d477 ENH Update deprecation messages 2022-10-18 14:49:04 +13:00
Guy Sartorelli 5aec7db4c5
Merge pull request #223 from creative-commoners/pulls/4/review-behat-tests
ENH New steps to creat groups and members with permissions
2022-09-06 12:34:03 +12:00
Sabina Talipova 4e00b8ee95 ENH New steps to creat groups and members with permissions 2022-09-06 12:22:55 +12:00
Steve Boyd c012736293 Merge branch '4.9' into 4 2022-08-02 19:05:29 +12:00
Steve Boyd 6956470cfc Merge branch '4.8' into 4.9 2022-08-02 19:05:25 +12:00
Guy Sartorelli 567b1302b9
Merge pull request #217 from creative-commoners/pulls/4.8/standardise-modules
MNT Standardise modules
2022-08-01 14:00:58 +12:00
Steve Boyd 706ee2be9d MNT Standardise modules 2022-08-01 09:42:18 +12:00
Steve Boyd e5e6c7256a Merge branch '4.9' into 4 2022-07-25 17:06:57 +12:00
Guy Sartorelli 7bb03997cf
Merge pull request #216 from creative-commoners/pulls/4.9/int
FIX Use usleep for fractions of a second
2022-07-25 16:04:22 +12:00
Steve Boyd c460781971 FIX Use usleep for fractions of a second 2022-07-25 15:28:43 +12:00
Steve Boyd 984c1fbd04 Merge branch '4.9' into 4 2022-07-25 09:56:37 +12:00
Steve Boyd 754b5e0969 Merge branch '4.8' into 4.9 2022-07-25 09:56:34 +12:00
Guy Sartorelli a2fb05bd8f
Merge pull request #215 from creative-commoners/pulls/4.8/module-standards
MNT Use GitHub Actions CI
2022-07-15 12:05:31 +12:00
Steve Boyd 68306cd7a8 MNT Use GitHub Actions CI 2022-07-05 22:17:14 +12:00
Guy Sartorelli ced518e890
Merge pull request #212 from creative-commoners/pulls/4/php81
ENH PHP 8.1 compatibility
2022-04-22 16:22:27 +12:00
Guy Sartorelli 5257536915
Merge pull request #213 from creative-commoners/pulls/4/remove-useless-config
MNT Remove config that does nothing and is confusing.
2022-04-14 13:24:13 +12:00
Guy Sartorelli e35b60d940 MNT Remove config that does nothing and is confusing.
The config here doesn't actually get applied to the in-browser sessions
and is misleading.
2022-04-14 10:00:14 +12:00
Steve Boyd 1f9fc6db05 ENH PHP 8.1 compatibility 2022-04-13 17:37:24 +12:00
Tim Oliver 261f88dd19
FIX When waiting for text to show on page, look for elements until we find one that is visible instead (#211)
of failing if the first element is invisible.
2022-03-29 14:10:29 +13:00
Steve Boyd 8ff1ef7a59 Merge branch '4.8' into 4 2022-03-24 10:39:47 +13:00
Maxime Rainville dcfa8ed1cd
Merge pull request #210 from creative-commoners/pulls/4.8/admin-email
FIX Define admin_email
2022-03-23 11:33:46 +13:00
Steve Boyd c7801bb1fb FIX Define admin_email 2022-03-22 13:02:06 +13:00
Daniel Hensby e2deed514e
Merge pull request #208 from creative-commoners/pulls/4/php74
DEP Set PHP 7.4 as the minimum version
2022-02-10 12:06:17 +00:00
Steve Boyd 92760462ac DEP Set PHP 7.4 as the minimum version 2022-02-10 15:39:25 +13:00
Steve Boyd efcdfb9c46 Merge branch '4.8' into 4 2021-11-18 16:55:08 +13:00
Steve Boyd b9b4c1d2a2
FIX Statically call assertion 2021-11-11 14:13:15 +13:00
Steve Boyd bc76e19d5d
NEW Skip MFA on login if present (#204) 2021-11-08 14:29:05 +13:00
Maxime Rainville 0939a30b12
Merge pull request #207 from creative-commoners/pulls/4/php8
DEP PHP8 support
2021-11-08 11:40:48 +13:00
Steve Boyd c7563fda09 DEP PHP8 support 2021-11-08 10:28:45 +13:00
Maxime Rainville 6ccf840e58
Merge pull request #205 from creative-commoners/pulls/4/sapphire-test-nine
API phpunit9 support
2021-10-27 17:24:11 +13:00
Steve Boyd 9ee6858f91 API phpunit9 support 2021-10-27 17:14:44 +13:00
Maxime Rainville ba2e93131e
Merge pull request #203 from creative-commoners/pulls/4/new-functions
NEW Add function to elements and select from dropdowns
2021-08-04 13:25:52 +12:00
Steve Boyd 76431b1190 NEW Add function to elements and select from dropdowns 2021-07-29 22:05:50 +12:00
Maxime Rainville 91be987eac
Merge pull request #202 from creative-commoners/pulls/4.3/click-element-confirm-dialog
NEW add iClickOnTheElementConfirmingTheDialog
2021-04-16 10:12:15 +12:00
Steve Boyd c754d70042 NEW add iClickOnTheElementConfirmingTheDialog 2021-04-15 13:57:58 +12:00
Steve Boyd d7321417b4 Merge branch '4.3' into 4 2021-04-15 13:55:07 +12:00
Maxime Rainville bcecdabbdf
Merge pull request #201 from creative-commoners/pulls/4.3/i-click-the-element
NEW Add iClickOnTheElement
2021-04-15 12:59:01 +12:00
Steve Boyd 420ceb8c0d NEW Add iClickOnTheElement 2021-04-15 12:13:29 +12:00
Steve Boyd fc9315123e Merge branch '4.0' into 4 2021-03-21 18:35:18 +13:00
Maxime Rainville efdd90b197
MNT Add support for symphony 4 and switch to shared travis config (#199)
* MNT Add support for symphony 4 and switch to shared travis config

* Specify DB

* Update phpunit config

* Don't test php 8

* Pleasing PHP8

* Use correct syntax for composer OR constraints

* Doing the constraint right ... maybe

Co-authored-by: Steve Boyd <emteknetnz@gmail.com>

Co-authored-by: Steve Boyd <emteknetnz@gmail.com>
2021-03-16 15:31:47 +13:00
Serge Latyntsev 51455664fa
Merge pull request #197 from creative-commoners/pulls/4.0/travis-shared
MNT Travis shared config, use sminnee/phpunit, fix SilverStripeContext regex
2021-02-11 17:00:29 +13:00
Steve Boyd bc581dc248
Update build status badge 2021-01-21 16:46:45 +13:00
Steve Boyd 4d6bd1890c MNT Travis shared config, use sminnee/phpunit 2021-01-19 11:33:18 +13:00
Ingo Schommer fe0818afc2
Clarify that behat-ss requires bash
See https://github.com/silverstripe/silverstripe-behat-extension/issues/187
2020-10-14 13:30:26 +13:00
Garion Herman d90f50fe10
Merge pull request #196 from creative-commoners/pull/4/sminnee-phpunit-support
MNT Add support for sminnee/phpunit fork
2020-10-08 14:50:20 +13:00
Serge Latyntcev 892aee9592 MNT Add support for sminnee/phpunit fork 2020-10-08 14:46:44 +13:00
Daniel Hensby 8dc02fd0db
Merge pull request #195 from creative-commoners/pulls/4/quote-yaml
MNT Quote yaml strings
2020-10-02 10:39:17 +01:00
Steve Boyd b4ae738fe0 MNT Quote yaml strings 2020-10-02 17:33:10 +13:00
Serge Latyntsev 8940d2882d
Merge pull request #193 from creative-commoners/pulls/4.0/travis
Update travis 4.0
2020-06-25 08:15:16 +12:00
Steve Boyd bae6d30561 Update travis 2020-06-24 12:52:21 +12:00
Maxime Rainville 6df2a983e3
BUG Avoid trying to get singletons fon non-existan classes (#192)
BUG Avoid trying to get singletons for non-existant classes
2020-04-29 09:54:45 +12:00
Aaron Carlino a1d77988ce
Merge pull request #190 from creative-commoners/pulls/4/start-driver-before-scenario
Fix WebDriverSession autostart
2020-03-17 16:27:23 +13:00
Serge Latyntcev e218fb85df FIX WebDriverSession autostart
Mink does not autostart web driver session anymore until the first ->visit invocation

Here's the breaking change: acf5fb1ec7

SilverStripeContext assumes the session is started when it prepares for the scenario (e.g. it may change the resolution)
2020-03-17 15:44:39 +13:00
Robbie Averill bd4a737833
Merge pull request #189 from open-sausages/pulls/4/smarter-stepIPressTheButton
BUG Update stepIPressTheButton to accept a list of button names separated by |
2019-09-24 15:03:02 -07:00
Maxime Rainville a594f44188 Rename click-oneoof-these-buttons method 2019-09-24 15:29:11 +12:00
Maxime Rainville 09cf3ca916 BUG Fix Travis build config 2019-09-24 13:58:47 +12:00
Maxime Rainville f94ff57604 BUG Add a method that allow multiple button to be search for and press 2019-09-24 13:58:15 +12:00
Robbie Averill 6ffbd879dc Update typo in docblock 2019-03-22 10:00:05 +13:00
Serge Latyntcev 64a4cd7469 Merge branch '4.0' into 4 2019-01-10 09:07:28 +13:00
Maxime Rainville 49100b5183
Merge pull request #186 from open-sausages/pulls/4/increment-teststate-dependency-for-4.1.0-release
MINOR Increment testsession requirement for 4.1 release
2019-01-09 13:31:41 +13:00
Maxime Rainville 74d8f3514e MINOR Increment testsession requirement for 4.1 release 2019-01-09 11:38:21 +13:00
Maxime Rainville f02fd0c11f
Merge pull request #185 from open-sausages/pulls/4.0/afterstep-wait-responses
ADD / After each step we wait for the server to handle all requests
2019-01-08 17:52:35 +13:00
Serge Latyntcev 52573518bd ADD / After each step we wait for the server to handle all requests 2018-12-20 09:37:13 +13:00
Robbie Averill 94dca0e56a Merge branch '4.0' into 4 2018-10-16 17:33:09 +02:00
Robbie Averill a7c076fee0 Remove obsolete branch alias 2018-10-16 17:32:58 +02:00
Robbie Averill 5146e6fac0
Merge pull request #184 from creative-commoners/pulls/4.0/save-me-from-unsaved
NEW Allows scenarios to anticipate an unsaved changes modal
2018-10-15 10:49:36 +02:00
Guy Marriott e892ef4829
NEW Allows scenarios to anticipate an unsaved changes modal 2018-10-15 11:26:34 +13:00
Robbie Averill 5ea8aae96d
Merge pull request #181 from creative-commoners/pulls/4/add-extensions
NEW Adding abilility to add an extension in a step
2018-08-28 17:02:48 +12:00
Guy Marriott b8d658114b
FIX Add missing "?" 2018-08-28 16:13:19 +12:00
Guy Marriott 2cbcab20c1
NEW Adding abilility to add an extension in a step 2018-08-21 14:47:44 +12:00
Robbie Averill 138456e497
Merge pull request #176 from creative-commoners/pulls/4/update-docs
DOCS Replace Firefox and Selenium server references with ChromeDriver
2018-08-14 10:21:22 +12:00
Raissa North 7c8ba06522 DOCS Replace references to Mozilla browser and Selenium server with ChromeDriver
Also update some outdated links to SilverStripe documentation as well as Behat user guide.
2018-08-14 10:20:44 +12:00
Damian Mooyman 894f7d5199
Merge pull request #179 from creative-commoners/pulls/master/add-supported-module-badge
Add supported module badge to readme
2018-06-18 10:13:47 +12:00
Dylan Wagstaff 1de04743d5 Add supported module badge to readme 2018-06-15 17:30:40 +12:00
Damian Mooyman 55e9221dcd
Merge pull request #174 from open-sausages/pulls/4.0/selenium-upgrade
API Update to use new facebook driver extension
2018-04-12 11:53:09 +12:00
Damian Mooyman 6ef427f467
Fix dependency 2018-03-16 11:52:01 +13:00
Damian Mooyman 3cf7db107b
Command for single-setup of behat tests 2018-03-16 11:42:37 +13:00
Damian Mooyman 42efff5eac
Bump testsession dependency 2018-03-16 10:07:59 +13:00
Damian Mooyman 5fdab55ebf API Update to use new facebook driver extension 2018-01-19 17:05:31 +13:00
Damian Mooyman 04789ceac1
Update branch alias for 3.x to 3.1.x-dev 2017-11-28 13:09:17 +13:00
Damian Mooyman 81a45e2819
Merge branch '3.0' into 3 2017-11-28 13:08:20 +13:00
Damian Mooyman b403d502da
Remove branch alias for 3.0 branch
Update .editorconfig
2017-11-28 13:08:03 +13:00
Damian Mooyman 4e36ec793e
Merge pull request #171 from open-sausages/pulls/recycle-dont-waste
Feature use existing fixture for member with the given id if found
2017-10-30 17:25:41 +13:00
Christopher Joe 1d3da4cd2f Feature use existing fixture for member with the given id if found, rather than always creating a new 2017-10-30 16:35:49 +13:00
Chris Joe 493ccb05cd Merge pull request #170 from open-sausages/pulls/1.0/environmen-env
Update to use new Environment::getenv()
2017-10-24 16:29:34 +13:00
Damian Mooyman 4657c39e24
Update to use new Environment::getenv() 2017-10-24 16:08:16 +13:00
35 changed files with 1856 additions and 626 deletions

View File

@ -14,6 +14,9 @@ trim_trailing_whitespace = true
indent_size = 2
indent_style = space
[composer.json]
indent_size = 4
# The indent size used in the package.json file cannot be changed:
# https://github.com/npm/npm/pull/3180#issuecomment-16336516

11
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,11 @@
name: CI
on:
push:
pull_request:
workflow_dispatch:
jobs:
ci:
name: CI
uses: silverstripe/gha-ci/.github/workflows/ci.yml@v1

16
.github/workflows/dispatch-ci.yml vendored Normal file
View File

@ -0,0 +1,16 @@
name: Dispatch CI
on:
# At 2:10 PM UTC, only on Sunday and Monday
schedule:
- cron: '10 14 * * 0,1'
jobs:
dispatch-ci:
name: Dispatch CI
# Only run cron on the silverstripe account
if: (github.event_name == 'schedule' && github.repository_owner == 'silverstripe') || (github.event_name != 'schedule')
runs-on: ubuntu-latest
steps:
- name: Dispatch CI
uses: silverstripe/gha-dispatch-ci@v1

17
.github/workflows/keepalive.yml vendored Normal file
View File

@ -0,0 +1,17 @@
name: Keepalive
on:
workflow_dispatch:
# The 8th of every month at 2:50pm UTC
schedule:
- cron: '50 14 8 * *'
jobs:
keepalive:
name: Keepalive
# Only run cron on the silverstripe account
if: (github.event_name == 'schedule' && github.repository_owner == 'silverstripe') || (github.event_name != 'schedule')
runs-on: ubuntu-latest
steps:
- name: Keepalive
uses: silverstripe/gha-keepalive@v1

View File

@ -1,39 +0,0 @@
language: php
dist: precise
sudo: false
cache:
directories:
- $HOME/.composer/cache/files
php:
- 5.6
env:
matrix:
- PHPUNIT_TEST=1
- PHPCS_TEST=1
matrix:
include:
- php: 5.6
env: PHPUNIT_TEST=1
- php: 7.0
env: PHPUNIT_TEST=1
- php: 7.1.2
env: PHPUNIT_TEST=1
before_script:
- export PATH=~/.composer/vendor/bin:$PATH
- composer validate
- composer install --dev --prefer-dist
- composer require --prefer-dist --no-update silverstripe/recipe-core:1.0.x-dev
- composer update
- if [[ $PHPCS_TEST ]]; then composer global require squizlabs/php_codesniffer:^3 --prefer-dist --no-interaction --no-progress --no-suggest -o; fi
- phpenv rehash
script:
- if [[ $PHPUNIT_TEST ]]; then vendor/bin/phpunit tests/php; fi
- if [[ $PHPCS_TEST ]]; then composer run-script lint; fi

148
README.md
View File

@ -1,14 +1,15 @@
# SilverStripe Integration for Behat
# Silverstripe Integration for Behat
[![Build Status](https://travis-ci.org/silverstripe-labs/silverstripe-behat-extension.svg?branch=master)](https://travis-ci.org/silverstripe-labs/silverstripe-behat-extension)
[![CI](https://github.com/silverstripe/silverstripe-behat-extension/actions/workflows/ci.yml/badge.svg)](https://github.com/silverstripe/silverstripe-behat-extension/actions/workflows/ci.yml)
[![Silverstripe supported module](https://img.shields.io/badge/silverstripe-supported-0071C4.svg)](https://www.silverstripe.org/software/addons/silverstripe-commercially-supported-module-list/)
## Overview
[Behat](http://behat.org) is a testing framework for behaviour-driven development.
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
[standard Behat usage instructions](http://docs.behat.org/).
a basic Silverstripe website, simply follow the
[standard Behat usage instructions](http://docs.behat.org/en/latest/user_guide.html).
This extension comes in handy if you want to go beyond
interacting with an existing website and database,
@ -17,7 +18,7 @@ would need to be rolled back to a "clean slate" later on.
It provides the following helpers:
* Provide access to SilverStripe classes in your Behat contexts
* Provide access to Silverstripe classes in your Behat contexts
* Set up a temporary database automatically
* Reset the database content on every scenario
* Prebuilt Contexts for SilverStripe's login forms and other common tasks
@ -29,7 +30,7 @@ It provides the following helpers:
In order to achieve this, the extension makes one basic assumption:
Your Behat tests are run from the same application as the tested
SilverStripe codebase, on a locally hosted website from the same codebase.
Silverstripe codebase, on a locally hosted website from the same codebase.
This is important because we need access to the underlying SilverStripe
PHP classes. You can of course use a remote browser to do the actual testing.
@ -37,56 +38,35 @@ Note: The extension has only been tested with the `selenium2` Mink driver.
## Installation
Simply [install SilverStripe through Composer](http://doc.silverstripe.org/framework/en/installation/composer).
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 4.x-dev
Switch to the newly created webroot, and add the SilverStripe Behat extension.
Switch to the newly created webroot, and add the Silverstripe Behat extension.
cd my-test-project
composer require --dev silverstripe/behat-extension:"@stable"
composer require --dev silverstripe/behat-extension
Now get the latest Selenium2 server (requires [Java](http://www.oracle.com/technetwork/java/javase/downloads/jdk8-downloads-2133151.html)):
Download the standalone [Google Chrome WebDriver](http://chromedriver.storage.googleapis.com/index.html?path=2.8/)
composer require --dev se/selenium-server-standalone:"2.x@stable"
Download [Firefox 31.0 ESR](https://ftp.mozilla.org/pub/firefox/releases/31.8.0esr/) (Extended Support Release).
This version is 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). Even newer Firefox ESR versions are likely to break with the Selenium version we're running.
Now install the SilverStripe project as usual by opening it in a browser and following the instructions.
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)
file one level above the webroot.
[`.env`](https://docs.silverstripe.org/en/getting_started/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
Unless you have [`SS_BASE_URL`](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:
export BEHAT_PARAMS="extensions[SilverStripe\BehatExtension\MinkExtension][base_url]=http://localhost/"
## Usage
### Prevent Firefox from Automatically Updating
The moment you open Firefox, it's going to try and update itself out of the stone age. To prevent this, open a new tab and go to `about:config`. There, change the following settings to `false`:
- `app.update.auto`
- `app.update.enabled`
- `app.update.silent`
Firefox will already have started the update, so close and delete it. The settings you changed should be stored as preferences, apart from the application files you've just deleted. Reinstall that ancient version. The next time you open it, and go to "About Firefox", you should see a button desperately pleading with you to "check for updates". Don't click that if you know what's good for you...
### Starting the Selenium Server
### Starting ChromeDriver
You can run the server locally in a separate Terminal session:
vendor/bin/selenium-server-standalone
In some cases it may be necessary to start a specific version of Firefox
vendor/bin/selenium-server-standalone -Dwebdriver.firefox.bin="/Applications/Firefox31.app/Contents/MacOS/firefox-bin"
chromedriver
### Running the Tests
@ -98,9 +78,23 @@ Or even run a single scenario by it's name (supports regular expressions):
vendor/bin/behat --name 'My scenario title' @framework
This will start a Firefox browser by default. Other browsers and profiles can be configured in `behat.yml`.
This will start a Chrome browser by default. Other browsers and profiles can be configured in `behat.yml`.
For example, if you want to start a Chrome Browser you can following the instructions provided [here](docs/chrome-behat.md).
### Running with stand-alone command (requires Bash)
If running with `silverstripe/serve` and `chromedriver`, you can also use the following command
which will automatically start and stop these services for individual tests.
vendor/bin/behat-ss @framework
This automates:
- starting server
- starting chromedriver
- running behat
- shutting down chromedriver
- shutting down server
Make sure you set `SS_BASE_URL` to `http://localhost:8080` in `.env`
## Tutorials
@ -110,18 +104,18 @@ For example, if you want to start a Chrome Browser you can following the instruc
## Configuration
The SilverStripe installer already comes with a YML configuration
which is ready to run tests on a locally hosted Selenium server,
The Silverstripe installer already comes with a YML configuration
which is ready to run tests on the standalone ChromeDriver server,
located in the project root as `behat.yml`.
You should ensure that you have configured SS_BASE_URL in your `.env`.
You should ensure that you have configured `SS_BASE_URL` in your `.env` file.
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):
* `ajax_steps`: Because SilverStripe uses AJAX requests quite extensively, we had to invent a way
* `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.
@ -151,14 +145,15 @@ Example: behat.yml
- %paths.modules.framework%/tests/behat/features/files/
extensions:
SilverStripe\BehatExtension\MinkExtension:
default_session: selenium2
javascript_session: selenium2
selenium2:
browser: firefox
default_session: facebook_web_driver
javascript_session: facebook_web_driver
facebook_web_driver:
browser: chrome
wd_host: "http://127.0.0.1:9515" #chromedriver port
SilverStripe\BehatExtension\Extension:
screenshot_path: %paths.base%/artifacts/screenshots
## Module Initialization
## Module Initialisation
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
@ -169,7 +164,7 @@ 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
(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
The Silverstripe Behat extension provides an initializer script which generates a template
in the recommended folder structure:
vendor/bin/behat --init @mymodule --namespace="MyVendor\MyModule"
@ -189,7 +184,7 @@ To find out all available steps (and the files they are defined in), run the fol
vendor/bin/behat @mymodule --definitions=i
Note: There are more specific step definitions in the SilverStripe `framework` module
Note: There are more specific step definitions in the Silverstripe `framework` module
for interacting with the CMS interfaces (see `framework/tests/behat/features/bootstrap`).
In addition to the dynamic list, a cheatsheet of available steps can be found at the end of this guide.
@ -240,10 +235,11 @@ use the inline definition syntax. The following example shows some syntax variat
Scenario: View a page in the tree
Given I am logged in with "ADMIN" permissions
And I go to "/admin/pages"
Then I should see "Page 1" in CMS Tree
Then I should see "Page 1"
* 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/en/latest/user_guide/writing_scenarios.html#backgrounds).
If you want them to be created only when a particular scenario runs, define them there.
* Fixtures are cleared between scenarios.
* The basic syntax works for all `DataObject` subclasses, but some specific
@ -260,7 +256,7 @@ 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
As a convention, Silverstripe Behat tests live in a `tests/behat` subfolder
of your module. You can create it with the following commands:
mkdir -p mymodule/tests/behat/features/
@ -268,7 +264,7 @@ of your module. You can create it with the following commands:
### FeatureContext
The generic [Behat usage instructions](http://docs.behat.org/) apply
The generic [Behat usage instructions](http://docs.behat.org/en/latest/user_guide.html) apply
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`.
@ -331,12 +327,10 @@ check `BasicContext->handleAjaxBeforeStep()` and the `ajax_steps` configuration
### Why does the module need to know about the framework path on the filesystem?
Sometimes SilverStripe needs to know the URL of your site. When you're visiting
Sometimes Silverstripe needs to know the URL of your site. When you're visiting
your site in a web browser this is easy to work out, but if you're executing
scripts on the command-line, it has no way of knowing.
To work this out, this module is using [file to URL mapping](http://doc.silverstripe.org/framework/en/topics/commandline#configuration).
### How does the module interact with the SS database?
The module creates temporary database on init and is switching to the alternative
@ -354,7 +348,7 @@ 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:
* Thirdparty SilverStripe modules which install default data
* Thirdparty Silverstripe modules which install default data
* Changes to the default interface language
* Configurations which remove admin areas or specific fields
@ -366,7 +360,7 @@ or run tests on a "sandbox" projects without these modules.
First, read the console output. Behat will tell you which steps have failed.
SilverStripe Behaviour Testing Framework also notifies you about some events.
Silverstripe Behaviour Testing Framework also notifies you about some events.
It tries to catch some JavaScript errors and AJAX errors as well although it
is limited to errors that occur after the page is loaded.
@ -405,13 +399,8 @@ The `macgdbp` IDE key needs to match your `xdebug.idekey` php.ini setting.
### How do I set up continuous integration through Travis?
Check out the [travis.yml](https://github.com/silverstripe/silverstripe-framework/blob/master/.travis.yml)
in `silverstripe/framework` for a good example on how to set up Behat tests through [travis-ci.org](http://travis-ci.org).
Note that the [Travis CI Environment](https://docs.travis-ci.com/user/ci-environment#Firefox)
does not default to the latest [Firefox ESR](https://www.mozilla.org/en-US/firefox/organizations/all/) release, but an older version.
(31.0 in January 2017). You should try to run your tests locally with the same Firefox version,
and [download the correct Firefox release](https://ftp.mozilla.org/pub/firefox/releases/)).
Alternatively, [configure Travis](https://docs.travis-ci.com/user/firefox/) to use a newer version.
Don't forget to disable auto-updates in your Firefox settings.
in `silverstripe/framework` for a good example on how to set up Behat tests through
[travis-ci.org](http://travis-ci.org).
## Cheatsheet
@ -635,7 +624,6 @@ It's based on the `vendor/bin/behat -di @cms` output.
Given /^the preview does not contain "([^"]*)"$/
### Fixtures
Given /^(?:(an|a|the) )"(?<type>[^"]+)" "(?<id>[^"]+)" (:?which )?redirects to (?:(an|a|the) )"(?<targetType>[^"]+)" "(?<targetId>[^"]+)"$/
@ -645,7 +633,7 @@ It's based on the `vendor/bin/behat -di @cms` output.
- Example: Given a "page" "Page 1"
Given /^(?:(an|a|the) )"(?<type>[^"]+)" "(?<id>[^"]+)" with (?<data>.*)$/
- Example: Given a "page" "Page 1" with "URL"="page-1" and "Content"="my page 1"
- Example: Given a "page" "Page 1" with "URLSegment"="page-1" and "Content"="my page 1"
Given /^(?:(an|a|the) )"(?<type>[^"]+)" "(?<id>[^"]+)" has the following data$/
- Example: And the "page" "Page 2" has the following data
@ -660,7 +648,7 @@ It's based on the `vendor/bin/behat -di @cms` output.
- Example: Given the "page" "Page 1" is deleted
Given /^there are the following ([^\s]*) records$/
- Accepts YAML fixture definitions similar to the ones used in SilverStripe unit testing.
- Accepts YAML fixture definitions similar to the ones used in Silverstripe unit testing.
Given /^(?:(an|a|the) )"member" "(?<id>[^"]+)" belonging to "(?<groupId>[^"]+)"$/
- Example: Given a "member" "Admin" belonging to "Admin Group"
@ -678,7 +666,7 @@ It's based on the `vendor/bin/behat -di @cms` output.
Given /^the CMS settings have the following data$/
- Example: Given the CMS settings has the following data
- Note: It only works with the SilverStripe CMS module installed
- Note: It only works with the Silverstripe CMS module installed
### Environment
@ -716,16 +704,22 @@ It's based on the `vendor/bin/behat -di @cms` output.
### Transformations
Behat [transformations](http://docs.behat.org/guides/2.definitions.html#step-argument-transformations)
Behat [transformations](http://docs.behat.org/en/v2.5/guides/2.definitions.html#step-argument-transformations)
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 (?<val>.*)$/`: 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 (?<val>.*)$/`: 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 (?<val>.*)$/`: 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)) time of (?<val>.*)$/`: 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 (?<val>.*)$/`: 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 (?<val>.*)$/`: 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
* [SilverStripe CMS architecture](http://doc.silverstripe.org/sapphire/en/trunk/reference/cms-architecture)
* [SilverStripe Framework Test Module](https://github.com/silverstripe-labs/silverstripe-frameworktest)
* [SilverStripe Unit and Integration Testing](http://doc.silverstripe.org/sapphire/en/trunk/topics/testing)
* [Silverstripe CMS architecture](http://doc.silverstripe.org/sapphire/en/trunk/reference/cms-architecture)
* [Silverstripe Framework Test Module](https://github.com/silverstripe-labs/silverstripe-frameworktest)
* [Silverstripe Unit and Integration Testing](http://doc.silverstripe.org/sapphire/en/trunk/topics/testing)

20
bin/behat-ss Executable file
View File

@ -0,0 +1,20 @@
#!/bin/sh
echo "setting up /artifacts"
mkdir -p artifacts
echo "starting chromedriver"
chromedriver &> artifacts/chromedriver.log 2> artifacts/chromedriver-error.log &
cd_pid=$!
echo "starting webserver"
vendor/bin/serve &> artifacts/serve.log 2> artifacts/serve-error.log &
ws_pid=$!
echo "starting behat"
vendor/bin/behat "$@"
echo "killing webserver (PID: $ws_pid)"
pkill -TERM -P $ws_pid &> /dev/null
echo "killing chromedriver (PID: $cd_pid)"
kill -9 $cd_pid &> /dev/null

View File

@ -1,61 +0,0 @@
<?php
/*
* This file is part of the Behat/SilverStripeExtension
*
* (c) Michał Ochman <ochman.d.michal@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
$filename = 'silverstripe_extension.phar';
if (file_exists($filename)) {
unlink($filename);
}
$phar = new \Phar($filename, 0, 'extension.phar');
$phar->setSignatureAlgorithm(\Phar::SHA1);
$phar->startBuffering();
foreach (findFiles('src') as $path) {
$phar->addFromString($path, file_get_contents(__DIR__ . '/' . $path));
}
$phar->addFromString('init.php', file_get_contents(__DIR__ . '/init.php'));
$phar->setStub(<<<STUB
<?php
/*
* This file is part of the SilverStripe-Behaviour-Testing-Framework
*
* (c) Michał Ochman <ochman.d.michal@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
Phar::mapPhar('extension.phar');
return require 'phar://extension.phar/init.php';
__HALT_COMPILER();
STUB
);
$phar->stopBuffering();
function findFiles($dir)
{
$iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir), RecursiveIteratorIterator::CHILD_FIRST);
$files = array();
foreach ($iterator as $path) {
if ($path->isFile()) {
$files[] = $path->getPath() . DIRECTORY_SEPARATOR . $path->getFilename();
}
}
return $files;
}

View File

@ -1,59 +1,51 @@
{
"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": "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": "^7.4 || ^8.0",
"phpunit/phpunit": "^9.5",
"squizlabs/php_codesniffer": "^3",
"behat/behat": "^3.9",
"behat/mink": "^1.7",
"behat/mink-extension": "^2.1",
"silverstripe/mink-facebook-web-driver": "^1",
"symfony/dom-crawler": "^3 || ^4",
"silverstripe/testsession": "^2.2",
"silverstripe/framework": "^4.10",
"symfony/finder": "^3.2 || ^4"
},
{
"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@alpha",
"silverstripe/framework": "^4@dev",
"symfony/finder": "^3.2"
},
"autoload": {
"psr-4": {
"SilverStripe\\BehatExtension\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"SilverStripe\\BehatExtension\\Tests\\": "tests/php/"
"autoload": {
"psr-4": {
"SilverStripe\\BehatExtension\\": "src/",
"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"
"extra": [],
"bin": [
"bin/behat-ss"
],
"scripts": {
"lint": "phpcs --standard=PSR2 -n src/ tests/php/"
},
"prefer-stable": true,
"minimum-stability": "dev"
}

View File

@ -8,6 +8,7 @@ parameters:
silverstripe_extension.ajax_steps: ~
silverstripe_extension.ajax_timeout: ~
silverstripe_extension.admin_url: ~
silverstripe_extension.is_ci: ~
silverstripe_extension.login_url: ~
silverstripe_extension.screenshot_path: ~
silverstripe_extension.module:
@ -17,13 +18,13 @@ parameters:
silverstripe_extension.context.class_path: tests/behat/src/
services:
silverstripe_extension.context.initializer:
class: %silverstripe_extension.context.initializer.class%
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%]]
- [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 }

View File

@ -1,29 +0,0 @@
#Running Behat Tests using Chrome
If you would like to run Behat Tests using Google Chrome here are a few steps I went through to get that setup.
1) [Download the Google Chrome Webdriver](http://chromedriver.storage.googleapis.com/index.html?path=2.8/)
2) Unzip the file, and place the chromedriver file in a known location.
3) Now edit the `behat.yml` file and create a new profile for using Chrome by adding the following below the default profile.
```
default_session: selenium2
javascript_session: selenium2
selenium2:
browser: chrome
SilverStripe\BehatExtension\Extension:
screenshot_path: %behat.paths.base%/_artifacts/screenshots
```
4) Now we need to use the new webdriver with Selenium.
```
java -jar selenium-server.jar -Dwebdriver.chrome.driver="/path/to/chromedriver"
```
5) Now run your behat steps with the chrome profile.
```
behat @mysite --profile=chrome
```

View File

@ -21,7 +21,7 @@ First of all, check out a default SilverStripe project
and ensure it runs on your environment. Detailed installation instructions
can be found in the [README](../README.md) of this module.
Once you've got the SilverStripe project running, make sure you've
started Selenium. With all configuration in place, initialize Behat
started ChromeDriver. With all configuration in place, initialise Behat
for
vendor/bin/behat --init @mysite
@ -246,7 +246,7 @@ not exactly one record found in the relation, and hence fail that step for Behat
public function thereShouldBeAnAbuseReportForWithReason($id, $reason)
{
$page = $this->fixtureFactory->get('Page', $id);
assertEquals(1, $page->PageAbuseReports()->filter('Reason', $reason)->Count());
Assert::assertEquals(1, $page->PageAbuseReports()->filter('Reason', $reason)->Count());
}
```

View File

@ -1,19 +0,0 @@
<?php
/*
* This file is part of the Behat/SilverStripeExtension
*
* (c) Michał Ochman <ochman.d.michal@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/
spl_autoload_register(function ($class) {
if (false !== strpos($class, 'SilverStripe\\BehatExtension')) {
require_once(__DIR__ . '/src/' . str_replace('\\', '/', $class) . '.php');
return true;
}
}, true, false);
return new SilverStripe\BehatExtension\Extension;

17
phpcs.xml.dist Normal file
View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<ruleset name="SilverStripe">
<description>CodeSniffer ruleset for SilverStripe coding conventions.</description>
<file>src</file>
<file>tests</file>
<!-- base rules are PSR-2 -->
<rule ref="PSR2" >
<!-- Current exclusions -->
<exclude name="PSR1.Methods.CamelCapsMethodName" />
<exclude name="PSR2.Classes.PropertyDeclaration.Underscore" />
<exclude name="Squiz.Classes.ValidClassName.NotCamelCaps" />
<exclude name="Generic.Files.LineLength.TooLong" />
<exclude name="PSR1.Files.SideEffects.FoundWithSymbols" />
</rule>
</ruleset>

View File

@ -1,5 +1,8 @@
<phpunit colors="true">
<testsuite name="Default">
<directory>tests/php</directory>
</testsuite>
<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="vendor/silverstripe/framework/tests/bootstrap.php" colors="true">
<testsuites>
<testsuite name="Default">
<directory>tests/php</directory>
</testsuite>
</testsuites>
</phpunit>

View File

@ -7,6 +7,7 @@ use SilverStripe\Control\Controller;
use SilverStripe\Control\HTTPApplication;
use SilverStripe\Control\HTTPRequest;
use SilverStripe\Core\CoreKernel;
use SilverStripe\Core\Environment;
use SilverStripe\Core\Manifest\ModuleLoader;
use SilverStripe\ORM\DataObject;
use Symfony\Component\DependencyInjection\ContainerBuilder;
@ -28,8 +29,8 @@ class CoreInitializationPass implements CompilerPassInterface
file_put_contents('php://stdout', 'Bootstrapping' . PHP_EOL);
// Connect to database and build manifest
if (!getenv('SS_ENVIRONMENT_TYPE')) {
putenv('SS_ENVIRONMENT_TYPE=dev');
if (!Environment::getEnv('SS_ENVIRONMENT_TYPE')) {
Environment::setEnv('SS_ENVIRONMENT_TYPE', 'dev');
}
// Include bootstrap file
@ -62,7 +63,7 @@ class CoreInitializationPass implements CompilerPassInterface
$container->setParameter('paths.modules.'.$module->getShortName(), $module->getPath());
$composerName = $module->getComposerName();
if ($composerName) {
list($vendor,$name) = explode('/', $composerName);
list($vendor,$name) = explode('/', $composerName ?? '');
$container->setParameter('paths.modules.'.$vendor.'.'.$name, $module->getPath());
}
}

View File

@ -3,6 +3,7 @@
namespace SilverStripe\BehatExtension\Compiler;
use InvalidArgumentException;
use SilverStripe\Core\Environment;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
@ -23,7 +24,7 @@ class MinkExtensionBaseUrlPass implements CompilerPassInterface
public function process(ContainerBuilder $container)
{
// Set url from environment
$baseURL = getenv('SS_BASE_URL');
$baseURL = Environment::getEnv('SS_BASE_URL');
if (!$baseURL) {
throw new InvalidArgumentException(
'"base_url" not configured. Please specify it in your .env config with SS_BASE_URL'

View File

@ -2,22 +2,31 @@
namespace SilverStripe\BehatExtension\Context;
use SilverStripe\Dev\Deprecation;
use Exception;
use InvalidArgumentException;
use Behat\Behat\Context\Context;
use Behat\Behat\Context\Environment\InitializedContextEnvironment;
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\Mink\Driver\Selenium2Driver;
use Behat\Mink\Element\NodeElement;
use Behat\Mink\Exception\ElementNotFoundException;
use Behat\Mink\Session;
use Behat\Testwork\Tester\Result\TestResult;
use Exception;
use Facebook\WebDriver\Exception\WebDriverException;
use Facebook\WebDriver\WebDriver;
use Facebook\WebDriver\WebDriverAlert;
use Facebook\WebDriver\WebDriverExpectedCondition;
use Facebook\WebDriver\WebDriverKeys;
use PHPUnit\Framework\Assert;
use SilverStripe\Assets\File;
use SilverStripe\Assets\Filesystem;
use SilverStripe\BehatExtension\Utility\StepHelper;
use WebDriver\Exception as WebDriverException;
use WebDriver\Session as WebDriverSession;
use SilverStripe\BehatExtension\Utility\DebugTools;
use SilverStripe\MinkFacebookWebDriver\FacebookWebDriver;
/**
* BasicContext
@ -31,6 +40,7 @@ class BasicContext implements Context
{
use MainContextAwareTrait;
use StepHelper;
use DebugTools;
/**
* Date format in date() syntax
@ -51,6 +61,50 @@ class BasicContext implements Context
*/
protected $datetimeFormat = 'Y-m-d H:i:s';
/**
* @var FixtureContext
*/
protected $fixtureContext = null;
/**
* Get the fixture context of the current module
*
* @BeforeScenario
*/
public function gatherContexts(BeforeScenarioScope $scope): void
{
/** @var InitializedContextEnvironment $environment */
$environment = $scope->getEnvironment();
// Find the FixtureContext defined in behat.yml
$subClasses = $this->getSubclassesOf(FixtureContext::class);
foreach ($subClasses as $class) {
if (!$environment->hasContextClass($class)) {
continue;
}
$this->fixtureContext = $environment->getContext($class);
break;
}
// Fallback to base FixtureClass
if (!$this->fixtureContext && $environment->hasContextClass(FixtureContext::class)) {
$this->fixtureContext = $environment->getContext(FixtureContext::class);
}
}
/**
* Gets the subclasses of a class
*/
private function getSubclassesOf($parent): array
{
$result = [];
foreach (get_declared_classes() as $class) {
if (is_subclass_of($class, $parent ?? '')) {
$result[] = $class;
}
}
return $result;
}
/**
* Get Mink session from MinkContext
*
@ -125,7 +179,7 @@ JS;
$jserrors = $page->find('xpath', '//body[@data-jserrors]');
if (null !== $jserrors) {
$this->takeScreenshot($event);
file_put_contents('php://stderr', $jserrors->getAttribute('data-jserrors') . PHP_EOL);
$this->logMessage($jserrors->getAttribute('data-jserrors'));
}
$javascript = <<<JS
@ -158,9 +212,9 @@ JS;
}
try {
$ajaxEnabledSteps = $this->getMainContext()->getAjaxSteps();
$ajaxEnabledSteps = implode('|', array_filter($ajaxEnabledSteps));
$ajaxEnabledSteps = implode('|', array_filter($ajaxEnabledSteps ?? []));
if (empty($ajaxEnabledSteps) || !preg_match('/(' . $ajaxEnabledSteps . ')/i', $event->getStep()->getText())) {
if (empty($ajaxEnabledSteps) || !preg_match('/(' . $ajaxEnabledSteps . ')/i', $event->getStep()->getText() ?? '')) {
return;
}
@ -211,9 +265,9 @@ JS;
}
try {
$ajaxEnabledSteps = $this->getMainContext()->getAjaxSteps();
$ajaxEnabledSteps = implode('|', array_filter($ajaxEnabledSteps));
$ajaxEnabledSteps = implode('|', array_filter($ajaxEnabledSteps ?? []));
if (empty($ajaxEnabledSteps) || !preg_match('/(' . $ajaxEnabledSteps . ')/i', $event->getStep()->getText())) {
if (empty($ajaxEnabledSteps) || !preg_match('/(' . $ajaxEnabledSteps . ')/i', $event->getStep()->getText() ?? '')) {
return;
}
@ -246,26 +300,6 @@ JS;
$this->getSession()->wait(100);
}
/**
* Take screenshot when step fails.
* Works only with Selenium2Driver.
*
* @AfterStep
* @param AfterStepScope $event
*/
public function takeScreenshotAfterFailedStep(AfterStepScope $event)
{
// Check failure code
if ($event->getTestResult()->getResultCode() !== TestResult::FAILED) {
return;
}
try {
$this->takeScreenshot($event);
} catch (WebDriverException $e) {
$this->logException($e);
}
}
/**
* Close modal dialog if test scenario fails on CMS page
*
@ -274,15 +308,17 @@ JS;
*/
public function closeModalDialog(AfterScenarioScope $event)
{
$expectsUnsavedChangesModal = $this->stepHasTag($event, 'unsavedChanges');
try {
// Only for failed tests on CMS page
if ($event->getTestResult()->getResultCode() === TestResult::FAILED) {
if ($expectsUnsavedChangesModal || $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->getWebDriverSession()->accept_alert();
$this->getExpectedAlert()->accept();
} catch (WebDriverException $e) {
// no-op, alert might not be present
}
@ -307,65 +343,17 @@ JS;
Filesystem::removeFolder(ASSETS_PATH, true);
}
/**
* Take a nice screenshot
*
* @param StepScope $event
*/
public function takeScreenshot(StepScope $event)
{
// Validate driver
$driver = $this->getSession()->getDriver();
if (!($driver instanceof Selenium2Driver)) {
file_put_contents('php://stdout', 'ScreenShots are only supported for Selenium2Driver: skipping');
return;
}
$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;
}
Filesystem::makeFolder($path);
$path = realpath($path);
if (!file_exists($path)) {
file_put_contents('php://stderr', sprintf('"%s" is not valid directory and failed to create it' . PHP_EOL, $path));
return;
}
if (file_exists($path) && !is_dir($path)) {
file_put_contents('php://stderr', sprintf('"%s" is not valid directory' . PHP_EOL, $path));
return;
}
if (file_exists($path) && !is_writable($path)) {
file_put_contents('php://stderr', sprintf('"%s" directory is not writable' . PHP_EOL, $path));
return;
}
$path = sprintf('%s/%s_%d.png', $path, basename($feature->getFile()), $step->getLine());
$screenshot = $driver->getWebDriverSession()->screenshot();
file_put_contents($path, base64_decode($screenshot));
file_put_contents('php://stderr', sprintf('Saving screenshot into %s' . PHP_EOL, $path));
}
/**
* @Given /^the page can't be found/
*/
public function stepPageCantBeFound()
{
$page = $this->getSession()->getPage();
assertTrue(
Assert::assertTrue(
// Content from ErrorPage default record
$page->hasContent('Page not found')
// Generic ModelAsController message
|| $page->hasContent('The requested page could not be found')
// Generic ModelAsController message
|| $page->hasContent('The requested page could not be found')
);
}
@ -376,7 +364,7 @@ JS;
*/
public function stepIWaitFor($secs)
{
$this->getSession()->wait((float)$secs*1000);
$this->getSession()->wait((float)$secs * 1000);
}
/**
@ -390,7 +378,7 @@ JS;
{
$page = $this->getSession()->getPage();
// See https://mathiasbynens.be/notes/css-escapes
$escapedTitle = addcslashes($title, '!"#$%&\'()*+,-./:;<=>?@[\]^`{|}~');
$escapedTitle = addcslashes($title ?? '', '!"#$%&\'()*+,-./:;<=>?@[\]^`{|}~');
$matchedEl = null;
$searches = [
['named', ['link_or_button', "'{$title}'"]],
@ -419,10 +407,10 @@ JS;
public function iShouldSeeAButton($negative, $text)
{
$button = $this->findNamedButton($text);
if (trim($negative)) {
assertNull($button, sprintf('%s button found', $text));
if (trim($negative ?? '')) {
Assert::assertNull($button, sprintf('%s button found', $text));
} else {
assertNotNull($button, sprintf('%s button not found', $text));
Assert::assertNotNull($button, sprintf('%s button not found', $text));
}
}
@ -433,7 +421,25 @@ JS;
public function stepIPressTheButton($text)
{
$button = $this->findNamedButton($text);
assertNotNull($button, "{$text} button not found");
Assert::assertNotNull($button, "{$text} button not found");
$button->click();
}
/**
* @Given /^I press the "([^"]*)" buttons$/
* @param string $text A list of button names can be provided by seperating the entries with the | character.
*/
public function stepIPressTheButtons($text)
{
$buttonNames = explode('|', $text ?? '');
foreach ($buttonNames as $name) {
$button = $this->findNamedButton(trim($name ?? ''));
if ($button) {
break;
}
}
Assert::assertNotNull($button, "{$text} button not found");
$button->click();
}
@ -464,6 +470,30 @@ JS;
$this->iDismissTheDialog();
}
/**
* @Given /^I click on the "([^"]+)" element$/
* @param string $selector
*/
public function iClickOnTheElement($selector)
{
$page = $this->getMainContext()->getSession()->getPage();
$element = $page->find('css', $selector);
Assert::assertNotNull($element, sprintf('Element %s not found', $selector));
$element->click();
}
/**
* Needs to be in single command to avoid "unexpected alert open" errors in Selenium.
*
* @When /^I click on the "([^"]+)" element, confirming the dialog$/
* @param $selector
*/
public function iClickOnTheElementConfirmingTheDialog($selector)
{
$this->iClickOnTheElement($selector);
$this->iConfirmTheDialog();
}
/**
* @Given /^I (click|double click) "([^"]*)" in the "([^"]*)" element$/
* @param string $clickType
@ -478,9 +508,9 @@ JS;
);
$page = $this->getSession()->getPage();
$parentElement = $page->find('css', $selector);
assertNotNull($parentElement, sprintf('"%s" element not found', $selector));
Assert::assertNotNull($parentElement, sprintf('"%s" element not found', $selector));
$element = $parentElement->find('xpath', sprintf('//*[count(*)=0 and contains(.,"%s")]', $text));
assertNotNull($element, sprintf('"%s" not found', $text));
Assert::assertNotNull($element, sprintf('"%s" not found', $text));
$clickTypeFn = $clickTypeMap[$clickType];
$element->$clickTypeFn();
}
@ -515,17 +545,25 @@ JS;
$this->iDismissTheDialog();
}
/**
* @Then /^the "([^"]+)" element "([^"]+)" attribute should be "([^"]*)"$/
*/
public function theElementAttributeShouldBe($selector, $attribute, $value)
{
$page = $this->getSession()->getPage();
$element = $page->find('css', $selector);
Assert::assertNotNull($element, sprintf('Element %s not found', $selector));
Assert::assertEquals($value, $element->getAttribute($attribute));
}
/**
* @Given /^I see the text "([^"]+)" in the alert$/
* @param string $expected
*/
public function iSeeTheDialogText($expected)
{
$session = $this->getSession();
/** @var Selenium2Driver $driver */
$driver = $session->getDriver();
$text = $driver->getWebDriverSession()->getAlert_text();
assertContains($expected, $text);
$text = $this->getExpectedAlert()->getText();
Assert::assertStringContainsString($expected, $text);
}
/**
@ -534,7 +572,24 @@ JS;
*/
public function iTypeIntoTheDialog($data)
{
$this->getWebDriverSession()->postAlert_text([ 'text' => $data ]);
$this->getExpectedAlert()
->sendKeys($data)
->accept();
}
/**
* Wait for alert to appear, and return handle
*
* @return WebDriverAlert
*/
protected function getExpectedAlert()
{
$session = $this->getWebDriverSession();
$session->wait()->until(
WebDriverExpectedCondition::alertIsPresent(),
"Alert is expected"
);
return $session->switchTo()->alert();
}
/**
@ -542,7 +597,12 @@ JS;
*/
public function iConfirmTheDialog()
{
$this->getWebDriverSession()->accept_alert();
$session = $this->getWebDriverSession();
$session->wait()->until(
WebDriverExpectedCondition::alertIsPresent(),
"Alert is expected"
);
$session->switchTo()->alert()->accept();
$this->handleAjaxTimeout();
}
@ -551,23 +611,23 @@ JS;
*/
public function iDismissTheDialog()
{
$this->getWebDriverSession()->dismiss_alert();
$this->getExpectedAlert()->dismiss();
$this->handleAjaxTimeout();
}
/**
* Get Selenium webdriver session.
* Note: Will fail if current driver isn't Selenium2Driver
* Note: Will fail if current driver isn't FacebookWebDriver
*
* @return WebDriverSession
* @return WebDriver
*/
protected function getWebDriverSession()
{
$driver = $this->getSession()->getDriver();
if (! $driver instanceof Selenium2Driver) {
throw new \InvalidArgumentException("Not supported for non-selenium2 drivers");
if (!$driver instanceof FacebookWebDriver) {
throw new InvalidArgumentException("Only supported for FacebookWebDriver");
}
return $driver->getWebDriverSession();
return $driver->getWebDriver();
}
/**
@ -575,9 +635,12 @@ JS;
* @param string $field
* @param string $path
* @return Call\Given
*
* @deprecated 4.5.0 Use iAttachTheFileToTheField() instead
*/
public function iAttachTheFileTo($field, $path)
{
Deprecation::notice('4.5.0', 'Use iAttachTheFileToTheField() instead');
// Remove wrapped button styling to make input field accessible to Selenium
$js = <<<JS
let input = jQuery('[name="$field"]');
@ -612,7 +675,7 @@ JS;
}
if (!$parent) {
throw new \InvalidArgumentException(sprintf('Input group with label "%s" cannot be found', $labelText));
throw new InvalidArgumentException(sprintf('Input group with label "%s" cannot be found', $labelText));
}
/** @var NodeElement $option */
@ -632,7 +695,7 @@ JS;
}
if (!$input) {
throw new \InvalidArgumentException(sprintf('Input "%s" cannot be found', $value));
throw new InvalidArgumentException(sprintf('Input "%s" cannot be found', $value));
}
$this->getSession()->getDriver()->click($input->getXPath());
@ -649,6 +712,7 @@ JS;
{
fwrite(STDOUT, "\033[s \033[93m[Breakpoint] Press \033[1;93m[RETURN]\033[0;93m to continue...\033[0m");
while (fgets(STDIN, 1024) == '') {
// noop
}
fwrite(STDOUT, "\033[u");
@ -667,14 +731,14 @@ JS;
*/
public function castRelativeToAbsoluteTime($prefix, $val)
{
$timestamp = strtotime($val);
$timestamp = strtotime($val ?? '');
if (!$timestamp) {
throw new \InvalidArgumentException(sprintf(
throw new InvalidArgumentException(sprintf(
"Can't resolve '%s' into a valid datetime value",
$val
));
}
return date($this->timeFormat, $timestamp);
return date($this->timeFormat ?? '', $timestamp);
}
/**
@ -689,14 +753,14 @@ JS;
*/
public function castRelativeToAbsoluteDatetime($prefix, $val)
{
$timestamp = strtotime($val);
$timestamp = strtotime($val ?? '');
if (!$timestamp) {
throw new \InvalidArgumentException(sprintf(
throw new InvalidArgumentException(sprintf(
"Can't resolve '%s' into a valid datetime value",
$val
));
}
return date($this->datetimeFormat, $timestamp);
return date($this->datetimeFormat ?? '', $timestamp);
}
/**
@ -711,14 +775,14 @@ JS;
*/
public function castRelativeToAbsoluteDate($prefix, $val)
{
$timestamp = strtotime($val);
$timestamp = strtotime($val ?? '');
if (!$timestamp) {
throw new \InvalidArgumentException(sprintf(
throw new InvalidArgumentException(sprintf(
"Can't resolve '%s' into a valid datetime value",
$val
));
}
return date($this->dateFormat, $timestamp);
return date($this->dateFormat ?? '', $timestamp);
}
public function getDateFormat()
@ -774,13 +838,13 @@ JS;
));
}
assertNotNull($element, sprintf("Element '%s' not found", $name));
Assert::assertNotNull($element, sprintf("Element '%s' not found", $name));
$disabledAttribute = $element->getAttribute('disabled');
if (trim($negate)) {
assertNull($disabledAttribute, sprintf("Failed asserting element '%s' is not disabled", $name));
if (trim($negate ?? '')) {
Assert::assertNull($disabledAttribute, sprintf("Failed asserting element '%s' is not disabled", $name));
} else {
assertNotNull($disabledAttribute, sprintf("Failed asserting element '%s' is disabled", $name));
Assert::assertNotNull($disabledAttribute, sprintf("Failed asserting element '%s' is disabled", $name));
}
}
@ -797,11 +861,11 @@ JS;
{
$page = $this->getSession()->getPage();
$fieldElement = $page->findField($field);
assertNotNull($fieldElement, sprintf("Field '%s' not found", $field));
Assert::assertNotNull($fieldElement, sprintf("Field '%s' not found", $field));
$disabledAttribute = $fieldElement->getAttribute('disabled');
assertNull($disabledAttribute, sprintf("Failed asserting field '%s' is enabled", $field));
Assert::assertNull($disabledAttribute, sprintf("Failed asserting field '%s' is enabled", $field));
}
/**
@ -820,11 +884,11 @@ JS;
{
$context = $this->getMainContext();
$regionObj = $context->getRegionObj($region);
assertNotNull($regionObj);
Assert::assertNotNull($regionObj);
$linkObj = $regionObj->findLink($link);
if (empty($linkObj)) {
throw new \Exception(sprintf('The link "%s" was not found in the region "%s"
throw new \Exception(sprintf('The link "%s" was not found in the region "%s"
on the page %s', $link, $region, $this->getSession()->getCurrentUrl()));
}
@ -846,18 +910,17 @@ JS;
{
$context = $this->getMainContext();
$regionObj = $context->getRegionObj($region);
assertNotNull($regionObj, "Region Object is null");
Assert::assertNotNull($regionObj, "Region Object is null");
$fieldObj = $regionObj->findField($field);
if (empty($fieldObj)) {
throw new \Exception(sprintf('The field "%s" was not found in the region "%s"
throw new \Exception(sprintf('The field "%s" was not found in the region "%s"
on the page %s', $field, $region, $this->getSession()->getCurrentUrl()));
}
$regionObj->fillField($field, $value);
}
/**
* Asserts text in a specific region (an element identified by a CSS selector, a "data-title" attribute,
* or a named region mapped to a CSS selector via Behat configuration).
@ -876,14 +939,14 @@ JS;
{
$context = $this->getMainContext();
$regionObj = $context->getRegionObj($region);
assertNotNull($regionObj);
Assert::assertNotNull($regionObj);
$actual = $regionObj->getText();
$actual = preg_replace('/\s+/u', ' ', $actual);
$regex = '/'.preg_quote($text, '/').'/ui';
$actual = preg_replace('/\s+/u', ' ', $actual ?? '');
$regex = '/' . preg_quote($text ?? '', '/') . '/ui';
if (trim($negate)) {
if (preg_match($regex, $actual)) {
if (trim($negate ?? '')) {
if (preg_match($regex ?? '', $actual ?? '')) {
$message = sprintf(
'The text "%s" was found in the text of the "%s" region on the page %s.',
$text,
@ -894,7 +957,7 @@ JS;
throw new \Exception($message);
}
} else {
if (!preg_match($regex, $actual)) {
if (!preg_match($regex ?? '', $actual ?? '')) {
$message = sprintf(
'The text "%s" was not found anywhere in the text of the "%s" region on the page %s.',
$text,
@ -920,7 +983,7 @@ JS;
'radio',
$this->getMainContext()->getXpathEscaper()->escapeLiteral($radioLabel)
]);
assertNotNull($radioButton);
Assert::assertNotNull($radioButton);
$session->getDriver()->click($radioButton->getXPath());
}
@ -934,7 +997,7 @@ JS;
$table = $this->getTable($selector);
$element = $table->find('named', array('content', "'$text'"));
assertNotNull($element, sprintf('Element containing `%s` not found in `%s` table', $text, $selector));
Assert::assertNotNull($element, sprintf('Element containing `%s` not found in `%s` table', $text, $selector));
}
/**
@ -947,7 +1010,7 @@ JS;
$table = $this->getTable($selector);
$element = $table->find('named', array('content', "'$text'"));
assertNull($element, sprintf('Element containing `%s` not found in `%s` table', $text, $selector));
Assert::assertNull($element, sprintf('Element containing `%s` not found in `%s` table', $text, $selector));
}
/**
@ -960,7 +1023,7 @@ JS;
$table = $this->getTable($selector);
$element = $table->find('xpath', sprintf('//*[count(*)=0 and contains(.,"%s")]', $text));
assertNotNull($element, sprintf('Element containing `%s` not found', $text));
Assert::assertNotNull($element, sprintf('Element containing `%s` not found', $text));
$element->click();
}
@ -988,17 +1051,17 @@ JS;
);
// Find tables by a <caption> field
$candidates += $page->findAll('xpath', "//table//caption[contains(normalize-space(string(.)),
$candidates += $page->findAll('xpath', "//table//caption[contains(normalize-space(string(.)),
$selector)]/ancestor-or-self::table[1]");
// Find tables by a .title node
$candidates += $page->findAll('xpath', "//table//*[contains(concat(' ',normalize-space(@class),' '), ' title ') and contains(normalize-space(string(.)),
$candidates += $page->findAll('xpath', "//table//*[contains(concat(' ',normalize-space(@class),' '), ' title ') and contains(normalize-space(string(.)),
$selector)]/ancestor-or-self::table[1]");
// Some tables don't have a visible title, so look for a fieldset with data-name instead
$candidates += $page->findAll('xpath', "//fieldset[@data-name=$selector]//table");
assertTrue((bool)$candidates, 'Could not find any table elements');
Assert::assertTrue((bool)$candidates, 'Could not find any table elements');
$table = null;
/** @var NodeElement $candidate */
@ -1008,7 +1071,7 @@ JS;
}
}
assertTrue((bool)$table, 'Found table elements, but none are visible');
Assert::assertTrue((bool)$table, 'Found table elements, but none are visible');
return $table;
}
@ -1016,7 +1079,7 @@ 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<textBefore>(?:[^"]|\\")*)" (before|after) the text "(?P<textAfter>(?:[^"]|\\")*)" in the "(?P<element>[^"]*)" element$/
* @Then /^I should see the text "(?P<textBefore>(?:[^"]|\\")*)" (?P<order>(before|after)) the text "(?P<textAfter>(?:[^"]|\\")*)" in the "(?P<element>[^"]*)" element$/
* @param string $textBefore
* @param string $order
* @param string $textAfter
@ -1025,19 +1088,19 @@ JS;
public function theTextBeforeAfter($textBefore, $order, $textAfter, $element)
{
$ele = $this->getSession()->getPage()->find('css', $element);
assertNotNull($ele, sprintf('%s not found', $element));
Assert::assertNotNull($ele, sprintf('%s not found', $element));
// Check both of the texts exist in the element
$text = $ele->getText();
assertTrue(strpos($text, $textBefore) !== 'FALSE', sprintf('%s not found in the element %s', $textBefore, $element));
assertTrue(strpos($text, $textAfter) !== 'FALSE', sprintf('%s not found in the element %s', $textAfter, $element));
Assert::assertTrue(strpos($text ?? '', $textBefore ?? '') !== 'FALSE', sprintf('%s not found in the element %s', $textBefore, $element));
Assert::assertTrue(strpos($text ?? '', $textAfter ?? '') !== 'FALSE', sprintf('%s not found in the element %s', $textAfter, $element));
/// Use strpos to get the position of the first occurrence of the two texts (case-sensitive)
// and compare them with the given order (before or after)
if ($order === 'before') {
assertTrue(strpos($text, $textBefore) < strpos($text, $textAfter));
Assert::assertTrue(strpos($text ?? '', $textBefore ?? '') < strpos($text ?? '', $textAfter ?? ''));
} else {
assertTrue(strpos($text, $textBefore) > strpos($text, $textAfter));
Assert::assertTrue(strpos($text ?? '', $textBefore ?? '') > strpos($text ?? '', $textAfter ?? ''));
}
}
@ -1101,16 +1164,20 @@ JS;
$page = $this->getSession()->getPage();
$session = $this->getSession();
$this->spin(function () use ($page, $session, $text) {
$element = $page->find(
$elements = $page->findAll(
'xpath',
$session->getSelectorsHandler()->selectorToXpath("xpath", ".//*[contains(text(), '$text')]")
);
if (empty($element)) {
return false;
} else {
return ($element->isVisible());
foreach ($elements as $element) {
if (empty($element)) {
continue;
}
if (!$element->isVisible()) {
continue;
}
return true;
}
return false;
});
}
@ -1146,11 +1213,11 @@ JS;
{
$page = $this->getSession()->getPage();
$el = $page->find('named', array($type, "'$locator'"));
assertNotNull($el, sprintf('%s element not found', $locator));
Assert::assertNotNull($el, sprintf('%s element not found', $locator));
$id = $el->getAttribute('id');
if (empty($id)) {
throw new \InvalidArgumentException('Element requires an "id" attribute');
throw new InvalidArgumentException('Element requires an "id" attribute');
}
$js = sprintf("document.getElementById('%s').scrollIntoView(true);", $id);
@ -1169,11 +1236,11 @@ JS;
public function iScrollToElement($locator)
{
$el = $this->getSession()->getPage()->find('css', $locator);
assertNotNull($el, sprintf('The element "%s" is not found', $locator));
Assert::assertNotNull($el, sprintf('The element "%s" is not found', $locator));
$id = $el->getAttribute('id');
if (empty($id)) {
throw new \InvalidArgumentException('Element requires an "id" attribute');
throw new InvalidArgumentException('Element requires an "id" attribute');
}
$js = sprintf("document.getElementById('%s').scrollIntoView(true);", $id);
@ -1214,6 +1281,15 @@ JS;
}
/**
* Log a message
*/
protected function logMessage(string $message)
{
file_put_contents('php://stderr', $message . PHP_EOL);
}
/**
* We have to catch exceptions and log somehow else otherwise behat falls over
*
@ -1221,6 +1297,256 @@ JS;
*/
protected function logException(Exception $exception)
{
file_put_contents('php://stderr', 'Exception caught: ' . $exception->getMessage());
$this->logMessage('Exception caught: ' . $exception->getMessage());
}
/**
* Detect element with javascript, rather than having the selector converted to xpath
* There's already an xpath based function 'I see the "" element' iSeeTheElement() in silverstripe/cms
* There's also an 'I should see "" element' in MinkContext which also converts the css selector to xpath
*
* @When /^I should(| not) see the "([^"]+)" element/
* @param $selector
*/
public function iShouldSeeTheElement($not, $cssSelector = '')
{
// backwards compatibility for when function signature was just ($cssSelector)
if (!in_array($not, ['', ' not'])) {
$not = '';
$cssSelector = $not;
}
$sel = str_replace('"', '\\"', $cssSelector ?? '');
$js = <<<JS
return document.querySelector("$sel");
JS;
$element = $this->getSession()->evaluateScript($js);
if ($not) {
Assert::assertNull($element, sprintf('Element %s was found when it should not have been', $cssSelector));
} else {
Assert::assertNotNull($element, sprintf('Element %s not found', $cssSelector));
}
}
/**
* Selects the option in select field with specified id|name|label|value
* Also accepts CSS selectors
*
* @When /^I select "([^"]+)" from the "([^"]+)" field(| with javascript)$/
* @param string $value
* @param string $locator - select id, name, label or element
* @param string $withJavascript - use javascript if having trouble selecting an option e.g. visibility
*/
public function iSelectFromTheField($value, $locator, $withJavascript)
{
$field = $this->getElement($locator);
if (!$withJavascript) {
$field->selectOption($value);
} else {
$xpath = $field->getXpath();
$xpath = str_replace(['"', "\n"], ['\"', ''], $xpath ?? '');
$value = str_replace('"', '\"', $value ?? '');
$js = <<<JS
return (function() {
let select = document.evaluate("{$xpath}", document).iterateNext();
let options = select.getElementsByTagName('option');
for (let i = 0; i < options.length; i++) {
let option = options[i];
if (option.value != "{$value}" && option.innerHTML.trim() != "{$value}") {
continue;
}
select.value = option.value;
return 1;
}
return 0;
})();
JS;
$result = $this->getSession()->evaluateScript($js);
Assert::assertEquals(1, $result, "Unable to select value {$value} from {$locator} with javascript");
}
}
/**
* @Then /^the rendered HTML should(| not) contain "(.+)"$/
* @param string $not
* @param string $htmlFragment
*/
public function theRenderedHtmlShouldContain($not, $htmlFragment)
{
$html = $this->getSession()->getPage()->getOuterHtml();
$htmlFragment = str_replace('\"', '"', $htmlFragment ?? '');
$contains = strpos($html ?? '', $htmlFragment ?? '') !== false;
if ($not) {
Assert::assertFalse($contains, "HTML fragment {$htmlFragment} was in rendered HTML when it should not have been");
} else {
Assert::assertTrue($contains, "HTML fragment {$htmlFragment} not found in rendered HTML");
}
}
/**
* Add tag values to the react TagField component which uses react-select
*
* @Then /^I add "([^"]+)" to the "([^"]+)" tag field$/
* @param string $value
* @param string $locator
*/
public function iAddToTheTagField($value, $locator)
{
$tagFieldInput = $this->getElement($locator);
$tagFieldInput->setValue($value);
$tagFieldInput->getParent()->getParent()->getParent()->getParent()->find('css', '.Select-menu-outer')->click();
}
/**
* @Then /^the "([^"]+)" field should have the value "([^"]+)"$/
* @param string $locator
* @param string $value
*/
public function theFieldShouldHaveTheValue($locator, $value)
{
Assert::assertEquals($value, $this->getElement($locator)->getValue());
}
/**
* Will first attempt to find a field based on $locator
* Will fall back to finding an element based on css selector
*
* @param string $locator
* @return null|NodeElement
*/
private function getElement($locator): ?NodeElement
{
$page = $this->getSession()->getPage();
try {
$element = $page->findField($locator);
} catch (ElementNotFoundException $e) {
// noop
}
if (!$element) {
$element = $page->find('css', $locator);
}
Assert::assertNotNull($element, "Field {$locator} was not found");
return $element;
}
/**
* @When /^I drag the "([^"]+)" element to the "([^"]+)" element$/
* @param string $locatorA
* @param string $locatorB
*/
public function iDragTheElementToTheElement($locatorA, $locatorB)
{
$elementA = $this->getElement($locatorA);
$elementB = $this->getElement($locatorB);
$elementA->dragTo($elementB);
}
/**
* This doesn't seem to work quite right in practice
* iDragTheElementToTheElement is much more reliable
*
* @When /^I drag the "([^"]+)" element by "(\-?[0-9]+),(\-?[0-9]+)"$/
* @param string $locatorA
* @param string $xOffset
* @param string $yOffset
*/
public function iDragTheElementBy($locatorA, $xOffset, $yOffset)
{
/** @var FacebookWebDrvier $driver */
$driver = $this->getSession()->getDriver();
if (!($driver instanceof FacebookWebDriver)) {
$this->logMessage('Drag and drop by offset is only supported for FacebookWebDriver: skipping');
return;
}
$elementA = $this->getElement($locatorA);
$driver->dragBy($elementA->getXpath(), (int) $xOffset, (int) $yOffset);
}
/**
* Globally press the key i.e. not type into an input
*
* @When /^I press the "([^"]+)" key globally$/
* @param string $keyCombo - e.g. tab / shift-tab / ctrl-c / alt-f4
*/
public function iPressTheKeyGlobally($keyCombo)
{
/** @var FacebookWebDrvier $driver */
$driver = $this->getSession()->getDriver();
if (!($driver instanceof FacebookWebDriver)) {
$this->logMessage('Pressing keys globally is only supported for FacebookWebDriver: skipping');
return;
}
$modifier = null;
$pos = strpos($keyCombo ?? '', '-');
if ($pos !== false && $pos !== 0) {
list($modifier, $char) = explode('-', $keyCombo ?? '');
} else {
$char = $keyCombo;
}
// handle special chars e.g. "space"
if (defined(WebDriverKeys::class . '::' . strtoupper($char ?? ''))) {
$char = constant(WebDriverKeys::class . '::' . strtoupper($char ?? ''));
}
if ($modifier) {
$modifier = strtoupper($modifier ?? '');
if (defined(WebDriverKeys::class . '::' . $modifier)) {
$modifier = constant(WebDriverKeys::class . '::' . $modifier);
} else {
$modifier = null;
}
}
$driver->globalKeyPress($char, $modifier);
}
/**
* Use upload fields
*
* @Then /^I attach the file "([^"]+)" to the "([^"]+)" field$/
* @param $filename
* @param $locator
*/
public function iAttachTheFileToTheField($filename, $locator)
{
Assert::assertNotNull($this->fixtureContext, 'FixtureContext was not found so cannot know location of fixture files');
$path = $this->fixtureContext->getFilesPath() . '/' . $filename;
$path = str_replace('//', '/', $path ?? '');
Assert::assertNotEmpty($path, 'Fixture files path is empty');
$field = $this->getElement($locator);
$filesPath = $this->fixtureContext->getFilesPath();
if ($filesPath) {
$fullPath = rtrim(realpath($filesPath ?? '') ?? '', DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $path;
if (is_file($fullPath ?? '')) {
$path = $fullPath;
}
}
Assert::assertFileExists($path, "{$path} does not exist");
$field->attachFile($path);
}
/**
* Use this to follow hyperlinks with target="_blank"
* Behat won't switch to the new tab
* Also allows use of css selectors
*
* @When /^I follow "([^"]+)" with javascript$/
* @param string $locator
*/
public function iFollowWithJavascript($locator)
{
$page = $this->getSession()->getPage();
$link = $page->find('named', ['link', $locator]);
if (!$link) {
$link = $page->find('css', $locator);
}
Assert::assertNotNull($link, "Link {$locator} was not found");
$html = $link->getOuterHtml();
preg_match('#href=([\'"])#', $html ?? '', $m);
$q = $m[1];
preg_match("#href={$q}(.+?){$q}#", $html ?? '', $m);
$href = str_replace("'", "\\'", $m[1] ?? '');
if (strpos($href ?? '', 'http') !== 0) {
$href = rtrim($href ?? '', '/');
$href = "/{$href}";
}
$this->getSession()->executeScript("document.location.href = '{$href}';");
}
}

View File

@ -6,8 +6,8 @@ use Behat\Behat\Context\Context;
use Behat\Behat\Hook\Scope\BeforeScenarioScope;
use Behat\Gherkin\Node\TableNode;
use Behat\Mink\Session;
use PHPUnit\Framework\Assert;
use SilverStripe\BehatExtension\Utility\TestMailer;
use SilverStripe\Control\Email\Email;
use SilverStripe\Control\Email\Mailer;
use SilverStripe\Core\Injector\Injector;
use Symfony\Component\DomCrawler\Crawler;
@ -50,7 +50,6 @@ class EmailContext implements Context
// to ensure its available both in CLI execution and the tested browser session
$this->mailer = new TestMailer();
Injector::inst()->registerService($this->mailer, Mailer::class);
Email::config()->update("send_all_emails_to", null);
}
/**
@ -64,10 +63,10 @@ class EmailContext implements Context
$to = ($direction == 'to') ? $email : null;
$from = ($direction == 'from') ? $email : null;
$match = $this->mailer->findEmail($to, $from);
if (trim($negate)) {
assertNull($match);
if (trim($negate ?? '')) {
Assert::assertNull($match);
} else {
assertNotNull($match);
Assert::assertNotNull($match);
}
$this->lastMatchedEmail = $match;
}
@ -88,8 +87,8 @@ class EmailContext implements Context
$allTitles = $allMails ? '"' . implode('","', array_map(function ($email) {
return $email->Subject;
}, $allMails)) . '"' : null;
if (trim($negate)) {
assertNull($match);
if (trim($negate ?? '')) {
Assert::assertNull($match);
} else {
$msg = sprintf(
'Could not find email %s "%s" titled "%s".',
@ -100,7 +99,7 @@ class EmailContext implements Context
if ($allTitles) {
$msg .= ' Existing emails: ' . $allTitles;
}
assertNotNull($match, $msg);
Assert::assertNotNull($match, $msg);
}
$this->lastMatchedEmail = $match;
}
@ -128,10 +127,10 @@ class EmailContext implements Context
$emailContent = $email->PlainContent;
}
if (trim($negate)) {
assertNotContains($content, $emailContent);
if (trim($negate ?? '')) {
Assert::assertStringNotContainsString($content, $emailContent);
} else {
assertContains($content, $emailContent);
Assert::assertStringContainsString($content, $emailContent);
}
}
@ -152,10 +151,10 @@ class EmailContext implements Context
$email = $this->lastMatchedEmail;
$emailContent = ($email->Content) ? ($email->Content) : ($email->PlainContent);
$emailPlainText = strip_tags($emailContent);
$emailPlainText = preg_replace("/\h+/", " ", $emailPlainText);
$emailPlainText = strip_tags($emailContent ?? '');
$emailPlainText = preg_replace("/\h+/", " ", $emailPlainText ?? '');
assertContains($content, $emailPlainText);
Assert::assertStringContainsString($content, $emailPlainText);
}
/**
@ -169,13 +168,13 @@ class EmailContext implements Context
$to = ($direction == 'to') ? $email : null;
$from = ($direction == 'from') ? $email : null;
$match = $this->mailer->findEmail($to, $from);
assertNotNull($match);
Assert::assertNotNull($match);
$crawler = new Crawler($match->Content);
$linkEl = $crawler->selectLink($linkSelector);
assertNotNull($linkEl);
Assert::assertNotNull($linkEl);
$link = $linkEl->attr('href');
assertNotNull($link);
Assert::assertNotNull($link);
$this->getMainContext()->visit($link);
}
@ -192,13 +191,13 @@ class EmailContext implements Context
$to = ($direction == 'to') ? $email : null;
$from = ($direction == 'from') ? $email : null;
$match = $this->mailer->findEmail($to, $from, $title);
assertNotNull($match);
Assert::assertNotNull($match);
$crawler = new Crawler($match->Content);
$linkEl = $crawler->selectLink($linkSelector);
assertNotNull($linkEl);
Assert::assertNotNull($linkEl);
$link = $linkEl->attr('href');
assertNotNull($link);
Assert::assertNotNull($link);
$this->getMainContext()->visit($link);
}
@ -218,9 +217,9 @@ class EmailContext implements Context
$match = $this->lastMatchedEmail;
$crawler = new Crawler($match->Content);
$linkEl = $crawler->selectLink($linkSelector);
assertNotNull($linkEl);
Assert::assertNotNull($linkEl);
$link = $linkEl->attr('href');
assertNotNull($link);
Assert::assertNotNull($link);
$this->getMainContext()->visit($link);
}
@ -257,18 +256,18 @@ class EmailContext implements Context
$emailContent = $email->PlainContent;
}
// Convert html content to plain text
$emailContent = strip_tags($emailContent);
$emailContent = preg_replace("/\h+/", " ", $emailContent);
$emailContent = strip_tags($emailContent ?? '');
$emailContent = preg_replace("/\h+/", " ", $emailContent ?? '');
$rows = $table->getRows();
// For "should not contain"
if (trim($negate)) {
if (trim($negate ?? '')) {
foreach ($rows as $row) {
assertNotContains($row[0], $emailContent);
Assert::assertStringNotContainsString($row[0], $emailContent);
}
} else {
foreach ($rows as $row) {
assertContains($row[0], $emailContent);
Assert::assertStringContainsString($row[0], $emailContent);
}
}
}
@ -281,14 +280,14 @@ class EmailContext implements Context
public function thereIsAnEmailTitled($negate, $subject)
{
$match = $this->mailer->findEmail(null, null, $subject);
if (trim($negate)) {
assertNull($match);
if (trim($negate ?? '')) {
Assert::assertNull($match);
} else {
$msg = sprintf(
'Could not find email titled "%s".',
$subject
);
assertNotNull($match, $msg);
Assert::assertNotNull($match, $msg);
}
$this->lastMatchedEmail = $match;
}
@ -305,10 +304,10 @@ class EmailContext implements Context
}
$match = $this->lastMatchedEmail;
if (trim($negate)) {
assertNotContains($from, $match->From);
if (trim($negate ?? '')) {
Assert::assertStringNotContainsString($from, $match->From);
} else {
assertContains($from, $match->From);
Assert::assertStringContainsString($from, $match->From);
}
}
@ -324,10 +323,10 @@ class EmailContext implements Context
}
$match = $this->lastMatchedEmail;
if (trim($negate)) {
assertNotContains($to, $match->To);
if (trim($negate ?? '')) {
Assert::assertStringNotContainsString($to, $match->To);
} else {
assertContains($to, $match->To);
Assert::assertStringContainsString($to, $match->To);
}
}
@ -353,12 +352,12 @@ class EmailContext implements Context
$href = null;
foreach ($tags as $tag) {
$linkText = $tag->nodeValue;
if (strpos($linkText, $httpText) !== false) {
if (strpos($linkText ?? '', $httpText ?? '') !== false) {
$href = $linkText;
break;
}
}
assertNotNull($href);
Assert::assertNotNull($href);
$this->getMainContext()->visit($href);
}

View File

@ -9,22 +9,28 @@ use Behat\Gherkin\Node\PyStringNode;
use Behat\Gherkin\Node\TableNode;
use Exception;
use InvalidArgumentException;
use PHPUnit\Framework\Assert;
use SilverStripe\Assets\Folder;
use SilverStripe\Assets\Storage\AssetStore;
use SilverStripe\Core\ClassInfo;
use SilverStripe\Core\Extensible;
use SilverStripe\Core\Extension;
use SilverStripe\Core\Injector\Injector;
use SilverStripe\Core\Manifest\ModuleLoader;
use SilverStripe\Core\Manifest\ModuleManifest;
use SilverStripe\Dev\BehatFixtureFactory;
use SilverStripe\Dev\FixtureBlueprint;
use SilverStripe\Dev\FixtureFactory;
use SilverStripe\Dev\SapphireTest;
use SilverStripe\Dev\YamlFixture;
use SilverStripe\ORM\Connect\TempDatabase;
use SilverStripe\ORM\DB;
use SilverStripe\ORM\DataExtension;
use SilverStripe\ORM\DataObject;
use SilverStripe\Versioned\Versioned;
use SilverStripe\ORM\DB;
use SilverStripe\Security\Group;
use SilverStripe\Security\Member;
use SilverStripe\Security\Permission;
use SilverStripe\Versioned\Versioned;
use SilverStripe\Core\Config\Config;
/**
* Context used to create fixtures in the SilverStripe ORM.
@ -55,10 +61,15 @@ class FixtureContext implements Context
protected $tempDatabase;
/**
* @var String Tracks all files and folders created from fixtures, for later cleanup.
* @var string[] Tracks all files and folders created from fixtures, for later cleanup.
*/
protected $createdFilesPaths = array();
/**
* @var string[] Tracks any config files that have been activated as part of a scenario
*/
protected $activatedConfigFiles = array();
/**
* @var array Stores the asset tuples.
*/
@ -247,12 +258,12 @@ class FixtureContext implements Context
$class = $this->convertTypeToClass($type);
preg_match_all(
'/"(?<key>[^"]+)"\s*=\s*"(?<value>[^"]+)"/',
$data,
$data ?? '',
$matches
);
$fields = $this->convertFields(
$class,
array_combine($matches['key'], $matches['value'])
array_combine($matches['key'] ?? [], $matches['value'] ?? [])
);
$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
@ -279,22 +290,23 @@ class FixtureContext implements Context
* @param string $null
* @param TableNode $fieldsTable
*/
public function stepCreateRecordWithTable($type, $id, $null, TableNode $fieldsTable)
public function stepCreateRecordWithTable($type, $id, TableNode $fieldsTable)
{
$class = $this->convertTypeToClass($type);
// TODO Support more than one record
$fields = $this->convertFields($class, $fieldsTable->getRowsHash());
$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);
}
}
@ -393,20 +405,20 @@ class FixtureContext implements Context
$manyField = null;
$oneField = null;
if ($relationObj->manyMany()) {
$manyField = array_search($class, $relationObj->manyMany());
if ($manyField && strlen($relationName) > 0) {
$manyField = array_search($class, $relationObj->manyMany() ?? []);
if ($manyField && strlen($relationName ?? '') > 0) {
$manyField = $relationName;
}
}
if (empty($manyField) && $relationObj->hasMany(true)) {
$manyField = array_search($class, $relationObj->hasMany());
if ($manyField && strlen($relationName) > 0) {
$manyField = array_search($class, $relationObj->hasMany() ?? []);
if ($manyField && strlen($relationName ?? '') > 0) {
$manyField = $relationName;
}
}
if (empty($manyField) && $relationObj->hasOne()) {
$oneField = array_search($class, $relationObj->hasOne());
if ($oneField && strlen($relationName) > 0) {
$oneField = array_search($class, $relationObj->hasOne() ?? []);
if ($oneField && strlen($relationName ?? '') > 0) {
$oneField = $relationName;
}
}
@ -526,7 +538,10 @@ class FixtureContext implements Context
}
/** @var Member $member */
$member = $this->getFixtureFactory()->createObject(Member::class, $id);
$member = $this->getFixtureFactory()->get(Member::class, $id);
if (!$member) {
$member = $this->getFixtureFactory()->createObject(Member::class, $id);
}
$member->Groups()->add($group);
}
@ -542,12 +557,12 @@ class FixtureContext implements Context
{
preg_match_all(
'/"(?<key>[^"]+)"\s*=\s*"(?<value>[^"]+)"/',
$data,
$data ?? '',
$matches
);
$fields = $this->convertFields(
Member::class,
array_combine($matches['key'], $matches['value'])
array_combine($matches['key'] ?? [], $matches['value'] ?? [])
);
/** @var Group $group */
@ -557,7 +572,10 @@ class FixtureContext implements Context
}
/** @var Member $member */
$member = $this->getFixtureFactory()->createObject(Member::class, $id, $fields);
$member = $this->getFixtureFactory()->get(Member::class, $id);
if (!$member) {
$member = $this->getFixtureFactory()->createObject(Member::class, $id, $fields);
}
$member->Groups()->add($group);
}
@ -571,7 +589,7 @@ class FixtureContext implements Context
public function stepCreateGroupWithPermissions($id, $permissionStr)
{
// Convert natural language permissions to codes
preg_match_all('/"([^"]+)"/', $permissionStr, $matches);
preg_match_all('/"([^"]+)"/', $permissionStr ?? '', $matches);
$permissions = $matches[1];
$codes = Permission::get_codes(false);
@ -626,6 +644,75 @@ class FixtureContext implements Context
$this->getMainContext()->getSession()->visit($this->getMainContext()->locatePath($link));
}
/**
* @param $extension
* @param $class
*
* @Given I add an extension :extension to the :class class
*/
public function iAddAnExtensionToTheClass($extension, $class)
{
// Validate the extension
Assert::assertTrue(
class_exists($extension ?? '') && is_subclass_of($extension, Extension::class),
'Given extension does not extend Extension'
);
// Add the extension to the CLI context
/** @var Extensible $targetClass */
try {
$targetClass = $this->convertTypeToClass($class);
} catch (InvalidArgumentException $e) {
// will end up here if the class is not a subclass of DataObject
if (class_exists($class)) {
$targetClass = $class;
} else {
throw $e;
}
}
$targetClass::add_extension($extension);
// Write config for this extension too...
$snakedExtension = strtolower(str_replace('\\', '-', $extension ?? '') ?? '');
$config = <<<YAML
---
Name: testonly-enable-extension-$snakedExtension
---
$class:
extensions:
- $extension
YAML;
$filename = 'enable-' . $snakedExtension . '.yml';
$destPath = $this->getDestinationConfigFolder($filename);
file_put_contents($destPath ?? '', $config);
// Remember to cleanup...
$this->activatedConfigFiles[] = $destPath;
// Flush website. We'll need to dev/build too if it's a DataExtension
if (is_subclass_of($extension, DataExtension::class)) {
$this->getMainContext()->visit('/dev/build?flush');
} else {
$this->getMainContext()->visit('/?flush');
}
}
/**
* Get the destination folder for config and assert the given file name doesn't exist within in.
*
* @param $filename
* @return string
*/
protected function getDestinationConfigFolder($filename)
{
$project = ModuleManifest::config()->get('project') ?: 'mysite';
$mysite = ModuleLoader::getModule($project);
Assert::assertNotNull($mysite, 'Project exists');
$destPath = $mysite->getResource("_config/{$filename}")->getPath();
Assert::assertFileDoesNotExist($destPath, "Config file {$filename} hasn't aleady been loaded");
return $destPath;
}
/**
* Checks that a file or folder exists in the webroot.
@ -637,7 +724,7 @@ class FixtureContext implements Context
*/
public function stepThereShouldBeAFileOrFolder($type, $path)
{
assertFileExists($this->joinPaths(BASE_PATH, $path));
Assert::assertFileExists($this->joinPaths(BASE_PATH, $path));
}
/**
@ -652,7 +739,7 @@ class FixtureContext implements Context
public function stepThereShouldBeAFileWithTuple($filename, $hash)
{
$exists = $this->getAssetStore()->exists($filename, $hash);
assertTrue((bool)$exists, "A file exists with filename $filename and hash $hash");
Assert::assertTrue((bool)$exists, "A file exists with filename $filename and hash $hash");
}
/**
@ -665,8 +752,8 @@ class FixtureContext implements Context
*/
public function lookupFixtureReference($string)
{
if (preg_match('/^=>/', $string)) {
list($className, $identifier) = explode('.', preg_replace('/^=>/', '', $string), 2);
if (preg_match('/^=>/', $string ?? '')) {
list($className, $identifier) = explode('.', preg_replace('/^=>/', '', $string ?? '') ?? '', 2);
$id = $this->getFixtureFactory()->getId($className, $identifier);
if (!$id) {
throw new InvalidArgumentException(sprintf(
@ -692,7 +779,7 @@ class FixtureContext implements Context
$class = $this->convertTypeToClass($type);
$fields = $this->prepareFixture($class, $id);
$record = $this->getFixtureFactory()->createObject($class, $id, $fields);
$date = date("Y-m-d H:i:s", strtotime($time));
$date = date("Y-m-d H:i:s", strtotime($time ?? ''));
$table = $record->baseTable();
$field = ($mod == 'created') ? 'Created' : 'LastEdited';
DB::prepared_query(
@ -708,6 +795,19 @@ class FixtureContext implements Context
}
}
/**
* Clean up all config files after scenario
*
* @AfterScenario
* @param AfterScenarioScope $event
*/
public function afterResetConfig(AfterScenarioScope $event)
{
$this->clearConfigFiles();
// Flush
$this->getMainContext()->visit('/?flush');
}
/**
* Prepares a fixture for use
*
@ -732,8 +832,8 @@ class FixtureContext implements Context
$relativeTargetPath = (isset($data['Filename'])) ? $data['Filename'] : $identifier;
$relativeTargetPath = preg_replace('/^' . ASSETS_DIR . '\/?/', '', $relativeTargetPath);
$sourcePath = $this->joinPaths($this->getFilesPath(), basename($relativeTargetPath));
$relativeTargetPath = preg_replace('/^' . ASSETS_DIR . '\/?/', '', $relativeTargetPath ?? '');
$sourcePath = $this->joinPaths($this->getFilesPath(), basename($relativeTargetPath ?? ''));
// Create file or folder on filesystem
if ($class == 'SilverStripe\\Assets\\Folder' || is_subclass_of($class, 'SilverStripe\\Assets\\Folder')) {
@ -741,7 +841,7 @@ class FixtureContext implements Context
$data['ID'] = $parent->ID;
} else {
// Check file exists
if (!file_exists($sourcePath)) {
if (!file_exists($sourcePath ?? '')) {
throw new InvalidArgumentException(sprintf(
'Source file for "%s" cannot be found in "%s"',
$relativeTargetPath,
@ -751,8 +851,8 @@ class FixtureContext implements Context
// Get parent
$parentID = 0;
if (strstr($relativeTargetPath, '/')) {
$folderName = dirname($relativeTargetPath);
if (strstr($relativeTargetPath ?? '', '/')) {
$folderName = dirname($relativeTargetPath ?? '');
$parent = Folder::find_or_make($folderName);
if ($parent) {
$parentID = $parent->ID;
@ -776,7 +876,7 @@ class FixtureContext implements Context
$data['FileVariant'] = $asset['Variant'];
}
if (!isset($data['Name'])) {
$data['Name'] = basename($relativeTargetPath);
$data['Name'] = basename($relativeTargetPath ?? '');
}
// Save assets
@ -795,6 +895,70 @@ class FixtureContext implements Context
return Injector::inst()->get(AssetStore::class);
}
/**
* Selects the first match of $select in the given HTML editor (tinymce)
*/
protected function selectInTheHtmlField(string $select, string $field)
{
$inputField = $this->getHtmlField($field);
$inputField->getParent()->find('css', 'iframe')->click();
$inputFieldId = $inputField->getAttribute('id');
$js = <<<JS
var editor = jQuery('#$inputFieldId').entwine('ss').getEditor(),
doc = editor.getInstance().getDoc(),
sel = doc.getSelection(),
rng = new Range(),
matched = false;
jQuery(doc).find("$select").each(function() {
if(!matched) {
rng.selectNode(this);
sel.removeAllRanges();
sel.addRange(rng);
matched = true;
}
});
JS;
$this->getMainContext()->getSession()->executeScript($js);
}
/**
* Selects the first image match in the HTML editor (tinymce)
*
* @When /^I select the image "([^"]+)" in the "([^"]+)" HTML field$/
* @param string $filename
* @param string $field
*/
public function iSelectTheImageInHtmlField($filename, $field)
{
$this->selectInTheHtmlField("img[src*='$filename']", $field);
}
/**
* Locate an HTML editor field
*
* @param string $locator Raw html field identifier as passed from
* @return NodeElement
*/
protected function getHtmlField($locator)
{
$locator = str_replace('\\"', '"', $locator ?? '');
$page = $this->getMainContext()->getSession()->getPage();
$element = $page->find('css', 'textarea.htmleditor[name=\'' . $locator . '\']');
if ($element) {
return $element;
}
$label = $page->findAll('xpath', sprintf('//label[contains(text(), \'%s\')]', $locator));
if (!empty($label)) {
Assert::assertCount(1, $label, "Found more than one element containing the phrase \"$locator\"");
$label = array_shift($label);
$fieldId = $label->getAttribute('for');
$element = $page->find('css', '#' . $fieldId);
}
Assert::assertNotNull($element, sprintf('HTML field "%s" not found', $locator));
return $element;
}
/**
* Converts a natural language class description to an actual class name.
* Respects {@link DataObject::$singular_name} variations.
@ -805,17 +969,17 @@ class FixtureContext implements Context
*/
protected function convertTypeToClass($type)
{
$type = trim($type);
$type = trim($type ?? '');
// Try direct mapping
$class = str_replace(' ', '', ucwords($type));
if (class_exists($class) && is_subclass_of($class, 'SilverStripe\\ORM\\DataObject')) {
$class = str_replace(' ', '', ucwords($type ?? ''));
if (class_exists($class ?? '') && is_subclass_of($class, DataObject::class)) {
return ClassInfo::class_name($class);
}
// Fall back to singular names
foreach (array_values(ClassInfo::subclassesFor('SilverStripe\\ORM\\DataObject')) as $candidate) {
if (strcasecmp(singleton($candidate)->singular_name(), $type) === 0) {
foreach (array_values(ClassInfo::subclassesFor(DataObject::class) ?? []) as $candidate) {
if (class_exists($candidate ?? '') && strcasecmp(singleton($candidate)->singular_name() ?? '', $type ?? '') === 0) {
return $candidate;
}
}
@ -838,7 +1002,7 @@ class FixtureContext implements Context
{
$labels = singleton($class)->fieldLabels();
foreach ($fields as $fieldName => $fieldVal) {
if ($fieldLabelKey = array_search($fieldName, $labels)) {
if ($fieldLabelKey = array_search($fieldName, $labels ?? [])) {
unset($fields[$fieldName]);
$fields[$labels[$fieldLabelKey]] = $fieldVal;
}
@ -854,11 +1018,34 @@ class FixtureContext implements Context
$paths = array_merge($paths, (array)$arg);
}
foreach ($paths as &$path) {
$path = trim($path, '/');
$path = trim($path ?? '', '/');
}
if (substr($args[0], 0, 1) == '/') {
if (substr($args[0] ?? '', 0, 1) == '/') {
$paths[0] = '/' . $paths[0];
}
return join('/', $paths);
}
protected function clearConfigFiles()
{
// No files to cleanup
if (empty($this->activatedConfigFiles)) {
return;
}
foreach ($this->activatedConfigFiles as $configFile) {
if (file_exists($configFile ?? '')) {
unlink($configFile ?? '');
}
}
$this->activatedConfigFiles = [];
}
/**
* Catch situations where failed scenarios and early exiting would prevent cleanup.
*/
public function __destruct()
{
$this->clearConfigFiles();
}
}

View File

@ -4,11 +4,14 @@ namespace SilverStripe\BehatExtension\Context;
use Behat\Behat\Context\Context;
use Behat\Mink\Element\NodeElement;
use LogicException;
use PHPUnit\Framework\Assert;
use SilverStripe\Security\Authenticator;
use SilverStripe\Security\Group;
use SilverStripe\Security\Member;
use SilverStripe\Security\Permission;
use SilverStripe\Security\Security;
use SilverStripe\MFA\Model\RegisteredMethod;
/**
* LoginContext
@ -30,9 +33,9 @@ class LoginContext implements Context
$this->getMainContext()->getSession()->visit($adminUrl);
if (0 == strpos($this->getMainContext()->getSession()->getCurrentUrl(), $loginUrl)) {
if (0 == strpos($this->getMainContext()->getSession()->getCurrentUrl() ?? '', $loginUrl ?? '')) {
$this->stepILogInWith('admin', 'password');
assertStringStartsWith($adminUrl, $this->getMainContext()->getSession()->getCurrentUrl());
Assert::assertStringStartsWith($adminUrl, $this->getMainContext()->getSession()->getCurrentUrl());
}
}
@ -51,6 +54,26 @@ class LoginContext implements Context
$this->stepILogInWith($email, $password);
}
/**
* Creates and login as a member of a group with the correct permissions.
* Example: Given I am logged in as a member of "ADMIN" group
*
* @Given /^I am logged in as a member of "([^"]*)" group$/
* @param string $groupName
*/
public function iAmLoggedInAsMemberOfGroup($groupName)
{
$group = Group::get()->filter('Title', "$groupName")->first();
if (!$group) {
throw new LogicException("Group $groupName does not exist");
}
$email = "{$groupName}@example.org";
$password = 'Secret!123';
$this->generateMember($email, $password, $group, $groupName);
$this->stepILogInWith($email, $password);
}
/**
* @Given /^I am not logged in$/
*/
@ -63,13 +86,13 @@ class LoginContext implements Context
$page = $this->getMainContext()->getSession()->getPage();
$form = $page->findById('LogoutForm_Form');
assertNotNull($form, 'Logout form not found');
Assert::assertNotNull($form, 'Logout form not found');
$submitButton = $form->find('css', '[type=submit]');
$securityID = $form->find('css', '[name=SecurityID]');
assertNotNull($submitButton, 'Submit button on logout form not found');
assertNotNull($securityID, 'CSRF token not found');
Assert::assertNotNull($submitButton, 'Submit button on logout form not found');
Assert::assertNotNull($securityID, 'CSRF token not found');
$submitButton->press();
}
@ -80,13 +103,77 @@ class LoginContext implements Context
* @param string $password
*/
public function stepILogInWith($email, $password)
{
$this->loginWith($email, $password);
// Check if MFA module is installed
if (!class_exists(RegisteredMethod::class)) {
return;
}
// Skip MFA registration if MFA module installed
$this->getMainContext()->getSession()->wait(100);
$page = $this->getMainContext()->getSession()->getPage();
$mfa = $this->waitForElement('#mfa-app');
if (!$mfa) {
return;
}
$clicked = false;
$cssLocator = '.mfa-action-list__item .btn';
$this->waitForElement($cssLocator);
foreach ($page->findAll('css', $cssLocator) as $btn) {
if ($btn->getText() !== 'Setup later') {
continue;
}
// There's been issues clicking the button, so try waiting for 0.3 seconds
usleep(0.3 * 1000000);
$btn->click();
$clicked = true;
break;
}
Assert::assertTrue($clicked, 'MFA "Setup later" button was not found so it was not clicked');
}
/**
* @param string $cssLocator
* @return NodeElement|null
*/
private function waitForElement($cssLocator)
{
$page = $this->getMainContext()->getSession()->getPage();
$el = null;
for ($i = 0; $i < 50; $i++) {
$el = $page->find('css', $cssLocator);
if ($el) {
break;
}
$this->getMainContext()->getSession()->wait(100);
}
return $el;
}
/**
* @When /^I log in with "([^"]*)" and "([^"]*)" without skipping MFA$/
* @param string $email
* @param string $password
*/
public function stepILogInWithWithoutSkippingMfa($email, $password)
{
$this->loginWith($email, $password);
}
/**
* @param string $email
* @param string $password
*/
private function loginWith($email, $password)
{
$c = $this->getMainContext();
$loginUrl = $c->joinUrlParts($c->getBaseUrl(), $c->getLoginUrl());
$this->getMainContext()->getSession()->visit($loginUrl);
$page = $this->getMainContext()->getSession()->getPage();
$form = $page->findById('MemberLoginForm_LoginForm');
assertNotNull($form, 'Login form not found');
Assert::assertNotNull($form, 'Login form not found');
// Try to find visible forms again on login page.
$visibleForm = null;
@ -94,21 +181,32 @@ class LoginContext implements Context
if ($form->isVisible() && $form->find('css', '[name=Email]')) {
$visibleForm = $form;
}
assertNotNull($visibleForm, 'Could not find login email field');
Assert::assertNotNull($visibleForm, 'Could not find login email field');
$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');
Assert::assertNotNull($emailField, 'Email field on login form not found');
Assert::assertNotNull($passwordField, 'Password field on login form not found');
Assert::assertNotNull($submitButton, 'Submit button on login form not found');
Assert::assertNotNull($securityID, 'CSRF token not found');
$emailField->setValue($email);
$passwordField->setValue($password);
$submitButton->press();
// Wait 100 ms
$this->getMainContext()->getSession()->wait(100);
// In case of login error, throw exception
// E.g. 'Your session has expired. Please re-submit the form.'
// This will allow @retry
$page = $this->getMainContext()->getSession()->getPage();
$message = $page->find('css', '.message.error');
$error = $message ? $message->getText() : null;
Assert::assertNull($message, 'Could not log in with user ' . $email . '. Error: "' . $error. '""');
}
/**
@ -118,7 +216,7 @@ class LoginContext implements Context
{
$page = $this->getMainContext()->getSession()->getPage();
$loginForm = $page->find('css', '#MemberLoginForm_LoginForm');
assertNotNull($loginForm, 'I should see a log-in form');
Assert::assertNotNull($loginForm, 'I should see a log-in form');
}
/**
@ -128,7 +226,7 @@ class LoginContext implements Context
{
$page = $this->getMainContext()->getSession()->getPage();
$logoutForm = $page->find('css', '#LogoutForm_Form');
assertNotNull($logoutForm, 'I should see a log-out form');
Assert::assertNotNull($logoutForm, 'I should see a log-out form');
}
/**
@ -139,7 +237,7 @@ class LoginContext implements Context
{
$page = $this->getMainContext()->getSession()->getPage();
$message = $page->find('css', sprintf('.message.%s', $type));
assertNotNull($message, sprintf('%s message not found.', $type));
Assert::assertNotNull($message, sprintf('%s message not found.', $type));
}
/**
@ -152,10 +250,10 @@ class LoginContext implements Context
{
/** @var Member $member */
$member = Member::get()->filter('Email', $id)->First();
assertNotNull($member);
Assert::assertNotNull($member);
$authenticators = Security::singleton()->getApplicableAuthenticators(Authenticator::CHECK_PASSWORD);
foreach ($authenticators as $authenticator) {
assertTrue($authenticator->checkPassword($member, $password)->isValid());
Assert::assertTrue($authenticator->checkPassword($member, $password)->isValid());
}
}
@ -168,22 +266,48 @@ class LoginContext implements Context
* @return Member
*/
protected function generateMemberWithPermission($email, $password, $permCode)
{
$group = $this->generateGroupWithPermission($permCode);
return $this->generateMember($email, $password, $group, $permCode);
}
/**
* Get or generate a group with the given permission code
*
* @param string $permCode
* @return Member
*/
protected function generateGroupWithPermission($permCode)
{
// Get or create group
$group = Group::get()->filter('Title', "$permCode group")->first();
if (!$group) {
$group = Group::create();
$group->Title = "$permCode group";
$group->write();
}
$group->Title = "$permCode group";
$group->write();
// Get or create permission
$permission = Permission::create();
$permission->Code = $permCode;
$permission->write();
$group->Permissions()->add($permission);
return $group;
}
/**
* Get or generate a member with the given permission code and permission group
*
* @param string $email
* @param string $password
* @param object $group
* @param string $identifier
* @return Member
*/
protected function generateMember($email, $password, $group, $identifier)
{
// Get or create member
$member = Member::get()->filter('Email', $email)->first();
if (!$member) {
@ -193,7 +317,7 @@ class LoginContext implements Context
// 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->FirstName = $identifier;
$member->Surname = "User";
$member->Email = $email;
$member->PasswordEncryption = "none";

View File

@ -2,17 +2,21 @@
namespace SilverStripe\BehatExtension\Context;
use Behat\Behat\Hook\Scope\AfterStepScope;
use Behat\Behat\Hook\Scope\BeforeScenarioScope;
use Behat\Mink\Element\NodeElement;
use Behat\Mink\Exception\ElementNotFoundException;
use Behat\Mink\Exception\UnsupportedDriverActionException;
use Behat\Mink\Selector\Xpath\Escaper;
use Behat\MinkExtension\Context\MinkContext;
use Behat\Mink\Driver\Selenium2Driver;
use Behat\Mink\Exception\UnsupportedDriverActionException;
use Behat\Mink\Exception\ElementNotFoundException;
use InvalidArgumentException;
use SilverStripe\BehatExtension\Utility\TestMailer;
use SilverStripe\CMS\Model\SiteTree;
use SilverStripe\Core\ClassInfo;
use SilverStripe\Core\Convert;
use SilverStripe\Core\Environment;
use SilverStripe\Core\Resettable;
use SilverStripe\MinkFacebookWebDriver\FacebookWebDriver;
use SilverStripe\ORM\DataObject;
use SilverStripe\TestSession\TestSessionEnvironment;
use Symfony\Component\CssSelector\Exception\SyntaxErrorException;
@ -83,7 +87,7 @@ abstract class SilverStripeContext extends MinkContext implements SilverStripeAw
*/
public function __construct(array $parameters = null)
{
if (!preg_match('/\\FeatureContext$/', get_class($this))) {
if (!preg_match('#[\\\]FeatureContext$#', get_class($this))) {
throw new InvalidArgumentException(
'Subclasses of SilverStripeContext must be named FeatureContext. Found "' . get_class($this) . '""'
);
@ -187,7 +191,7 @@ abstract class SilverStripeContext extends MinkContext implements SilverStripeAw
$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;
@ -199,7 +203,7 @@ abstract class SilverStripeContext extends MinkContext implements SilverStripeAw
// Fall back to region identified by data-title.
// Only apply if no double quotes exist in search string,
// which would break the CSS selector.
if (false === strpos($region, '"')) {
if (false === strpos($region ?? '', '"')) {
$regionObj = $this->getSession()->getPage()->find(
'css',
'[data-title="' . $region . '"]'
@ -213,7 +217,7 @@ abstract class SilverStripeContext extends MinkContext implements SilverStripeAw
if (!$this->regionMap) {
throw new \LogicException("Cannot find 'region_map' in the behat.yml");
}
if (!array_key_exists($region, $this->regionMap)) {
if (!array_key_exists($region, $this->regionMap ?? [])) {
throw new \LogicException("Cannot find the specified region in the behat.yml");
}
$regionObj = $this->getSession()->getPage()->find('css', $region);
@ -236,6 +240,11 @@ abstract class SilverStripeContext extends MinkContext implements SilverStripeAw
);
}
$webDriverSession = $this->getSession();
if (!$webDriverSession->isStarted()) {
$webDriverSession->start();
}
$state = $this->getTestSessionState();
$this->testSessionEnvironment->startTestSession($state);
@ -255,8 +264,8 @@ abstract class SilverStripeContext extends MinkContext implements SilverStripeAw
$this->testSessionEnvironment->loadFixtureIntoDb($fixtureFile);
}
if ($screenSize = getenv('BEHAT_SCREEN_SIZE')) {
list($screenWidth, $screenHeight) = explode('x', $screenSize);
if ($screenSize = Environment::getEnv('BEHAT_SCREEN_SIZE')) {
list($screenWidth, $screenHeight) = explode('x', $screenSize ?? '');
$this->getSession()->resizeWindow((int)$screenWidth, (int)$screenHeight);
} else {
$this->getSession()->resizeWindow(1024, 768);
@ -273,6 +282,27 @@ abstract class SilverStripeContext extends MinkContext implements SilverStripeAw
}
}
/**
* @AfterStep
*
* Wait for all requests to be handled after each step
*
* @param AfterStepScope $event
*/
public function waitResponsesAfterStep(AfterStepScope $event)
{
$success = $this->testSessionEnvironment->waitForPendingRequests();
if (!$success) {
echo (
'Warning! The timeout for waiting a response from the server has expired...'.PHP_EOL.
'I keep going on, but this test behaviour may be inconsistent.'.PHP_EOL.PHP_EOL.
'Your action required!'.PHP_EOL.PHP_EOL.
'You may want to investigate why the server is responding that slowly.'.PHP_EOL.
'Otherwise, you may need to increase the timeout.'
);
}
}
/**
* Returns a parameter map of state to set within the test session.
* Takes TESTSESSION_PARAMS environment variable into account for run-specific configurations.
@ -282,11 +312,11 @@ abstract class SilverStripeContext extends MinkContext implements SilverStripeAw
public function getTestSessionState()
{
$extraParams = array();
parse_str(getenv('TESTSESSION_PARAMS'), $extraParams);
parse_str(Environment::getEnv('TESTSESSION_PARAMS') ?? '', $extraParams);
return array_merge(
array(
'database' => $this->databaseName,
'mailer' => 'SilverStripe\BehatExtension\Utility\TestMailer',
'mailer' => TestMailer::class,
),
$extraParams
);
@ -300,13 +330,13 @@ abstract class SilverStripeContext extends MinkContext implements SilverStripeAw
*/
public function parseUrl($url)
{
$url = parse_url($url);
$url = parse_url($url ?? '');
$url['vars'] = array();
if (!isset($url['fragment'])) {
$url['fragment'] = null;
}
if (isset($url['query'])) {
parse_str($url['query'], $url['vars']);
parse_str($url['query'] ?? '', $url['vars']);
}
return $url;
@ -371,7 +401,7 @@ abstract class SilverStripeContext extends MinkContext implements SilverStripeAw
$parts = func_get_args();
$trimSlashes = function (&$part) {
$part = trim($part, '/');
$part = trim($part ?? '', '/');
};
array_walk($parts, $trimSlashes);
@ -381,7 +411,7 @@ abstract class SilverStripeContext extends MinkContext implements SilverStripeAw
public function canIntercept()
{
$driver = $this->getSession()->getDriver();
if ($driver instanceof Selenium2Driver) {
if ($driver instanceof FacebookWebDriver) {
return false;
}
@ -409,7 +439,18 @@ abstract class SilverStripeContext extends MinkContext implements SilverStripeAw
/** @var NodeElement $node */
foreach ($nodes as $node) {
if ($node->isVisible()) {
$node->setValue($value);
// Work around for https://github.com/FluentLenium/FluentLenium/issues/129
// Otherwise "Element must be user-editable in order to clear it"
$type = $node->getAttribute('type');
$id = $node->getAttribute('id');
if ($type === 'date' && $id) {
$jsValue = Convert::raw2js($value);
$this->getSession()->getDriver()->executeScript(
"document.getElementById(\"{$id}\").value = \"{$jsValue}\";"
);
} else {
$node->setValue($value);
}
return;
}
}
@ -424,7 +465,7 @@ abstract class SilverStripeContext extends MinkContext implements SilverStripeAw
}
/**
* Overwritten to click the first *visable* link the DOM.
* Overwritten to click the first *visible* link the DOM.
*
* @param string $link
* @throws ElementNotFoundException
@ -469,7 +510,7 @@ abstract class SilverStripeContext extends MinkContext implements SilverStripeAw
}
$state = $this->testSessionEnvironment->getState();
$oldDatetime = \DateTime::createFromFormat('Y-m-d H:i:s', isset($state->datetime) ? $state->datetime : null);
$oldDatetime = \DateTime::createFromFormat('Y-m-d H:i:s', $state->datetime ?? '');
if ($oldDatetime) {
$newDatetime->setTime($oldDatetime->format('H'), $oldDatetime->format('i'), $oldDatetime->format('s'));
}
@ -559,7 +600,7 @@ abstract class SilverStripeContext extends MinkContext implements SilverStripeAw
$value = $field->getValue();
$newValue = $opt->getAttribute('value');
if (is_array($value)) {
if (!in_array($newValue, $value)) {
if (!in_array($newValue, $value ?? [])) {
$value[] = $newValue;
}
} else {

View File

@ -17,8 +17,8 @@ trait ModuleCommandTrait
*/
protected function getModule($name, $error = true)
{
if (strpos($name, '@') === 0) {
$name = substr($name, 1);
if (strpos($name ?? '', '@') === 0) {
$name = substr($name ?? '', 1);
}
$module = ModuleLoader::inst()->getManifest()->getModule($name);
if (!$module && $error) {

View File

@ -138,11 +138,11 @@ class ModuleInitialisationController implements Controller
{
// Create feature_path
$features = $this->container->getParameter('silverstripe_extension.context.features_path');
$fullPath = $module->getResourcePath($features);
if (is_dir($fullPath)) {
$fullPath = $module->getResource($features)->getPath();
if (is_dir($fullPath ?? '')) {
return;
}
mkdir($fullPath, 0777, true);
mkdir($fullPath ?? '', 0777, true);
$output->writeln(
"<info>{$fullPath}</info> - <comment>place your *.feature files here</comment>"
);
@ -164,14 +164,14 @@ class ModuleInitialisationController implements Controller
protected function initClassPath(OutputInterface $output, Module $module, $namespaceRoot)
{
$classesPath = $this->container->getParameter('silverstripe_extension.context.class_path');
$dirPath = $module->getResourcePath($classesPath);
if (!is_dir($dirPath)) {
mkdir($dirPath, 0777, true);
$dirPath = $module->getResource($classesPath)->getPath();
if (!is_dir($dirPath ?? '')) {
mkdir($dirPath ?? '', 0777, true);
}
// Scaffold base context file
$classPath = "{$dirPath}/FeatureContext.php";
if (is_file($classPath)) {
if (is_file($classPath ?? '')) {
return;
}
@ -185,7 +185,7 @@ class ModuleInitialisationController implements Controller
'ClassName' => $class,
]);
$classContent = $obj->renderWith(__DIR__.'/../../templates/FeatureContext.ss');
file_put_contents($classPath, $classContent);
file_put_contents($classPath ?? '', $classContent);
// Log
$output->writeln(
@ -193,13 +193,13 @@ class ModuleInitialisationController implements Controller
);
// Add to composer json
$composerFile = $module->getResourcePath('composer.json');
if (!file_exists($composerFile)) {
$composerFile = $module->getResource('composer.json')->getPath();
if (!file_exists($composerFile ?? '')) {
return;
}
// Add autoload directive to composer
$composerData = json_decode(file_get_contents($composerFile), true);
$composerData = json_decode(file_get_contents($composerFile ?? '') ?? '', true);
if (json_last_error()) {
throw new Exception(json_last_error_msg());
}
@ -211,7 +211,7 @@ class ModuleInitialisationController implements Controller
}
$composerData['autoload']['psr-4']["{$fullNamespace}\\"] = $classesPath;
file_put_contents(
$composerFile,
$composerFile ?? '',
json_encode($composerData, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)
);
@ -239,7 +239,7 @@ class ModuleInitialisationController implements Controller
protected function getFixtureNamespace($namespaceRoot)
{
$namespaceSuffix = $this->container->getParameter('silverstripe_extension.context.namespace_suffix');
return trim($namespaceRoot, '/\\') . '\\' . $namespaceSuffix;
return trim($namespaceRoot ?? '', '/\\') . '\\' . $namespaceSuffix;
}
/**
@ -251,8 +251,8 @@ class ModuleInitialisationController implements Controller
*/
protected function initConfig($output, $module, $namespaceRoot)
{
$configPath = $module->getResourcePath('behat.yml');
if (file_exists($configPath)) {
$configPath = $module->getResource('behat.yml')->getPath();
if (file_exists($configPath ?? '')) {
return;
}
$class = $this->getFixtureClass($namespaceRoot);
@ -279,7 +279,7 @@ class ModuleInitialisationController implements Controller
]
]
];
file_put_contents($configPath, Yaml::dump($data, 99999999, 2));
file_put_contents($configPath ?? '', Yaml::dump($data, 99999999, 2));
$output->writeln(
"<info>{$configPath}</info> - <comment>default behat.yml created</comment>"

View File

@ -158,7 +158,7 @@ class ModuleSuiteLocator implements Controller
// Find all candidate paths
foreach ([ "{$path}/", "{$path}/{$pathSuffix}"] as $parent) {
foreach ([$parent.'behat.yml', $parent.'.behat.yml'] as $candidate) {
if (file_exists($candidate)) {
if (file_exists($candidate ?? '')) {
return $candidate;
}
}
@ -178,7 +178,7 @@ class ModuleSuiteLocator implements Controller
{
$path = $this->findModuleConfig($module);
$yamlParser = new Parser();
$config = $yamlParser->parse(file_get_contents($path));
$config = $yamlParser->parse(file_get_contents($path ?? ''));
if (empty($config['default']['suites'][$suite])) {
throw new Exception("Path {$path} does not contain default.suites.{$suite} config");
}

View File

@ -15,8 +15,12 @@ use Symfony\Component\DependencyInjection\Loader\YamlFileLoader;
use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition;
use Behat\Testwork\ServiceContainer\ExtensionManager;
use Behat\Testwork\ServiceContainer\Extension as ExtensionInterface;
use RuntimeException;
use Symfony\Component\DependencyInjection\Definition;
use Symfony\Component\DependencyInjection\Reference;
use Behat\Behat\Tester\ServiceContainer\TesterExtension;
use SilverStripe\BehatExtension\Utility\RerunTotalStatistics;
use SilverStripe\BehatExtension\Utility\RerunRuntimeSuiteTester;
/*
* This file is part of the SilverStripe\BehatExtension
@ -52,8 +56,26 @@ class Extension implements ExtensionInterface
public function initialize(ExtensionManager $extensionManager)
{
// PHPUnit
require_once BASE_PATH . '/vendor/phpunit/phpunit/src/Framework/Assert/Functions.php';
// Find PHPUnit assertion implementations
$found = false;
$options = [
BASE_PATH . '/vendor/sminnee/phpunit/src/Framework/Assert/Functions.php',
BASE_PATH . '/vendor/phpunit/phpunit/src/Framework/Assert/Functions.php'
];
foreach ($options as $file) {
if (file_exists($file ?? '')) {
require_once $file;
$found = true;
break;
}
}
if (!$found) {
throw new RuntimeException('Could not find PHPUnit installation');
}
}
public function load(ContainerBuilder $container, array $config)
@ -79,6 +101,22 @@ class Extension implements ExtensionInterface
$container->setParameter('silverstripe_extension.region_map', $config['region_map']);
}
$container->setParameter('silverstripe_extension.bootstrap_file', $config['bootstrap_file']);
$container->setParameter('silverstripe_extension.is_ci', $config['is_ci']);
// When running in CI, behat scenarios will occasionally sporadically fail
// Replaces services with custom implementations that will rerun failed features
// Note that features rather than scenarios need to be rerun to ensure that
// everything is setup and torn down correctly and that "Background" bits of
// feature fits are rerun
if ($config['is_ci']) {
$definition = new Definition(RerunRuntimeSuiteTester::class, array(
new Reference(TesterExtension::SPECIFICATION_TESTER_ID)
));
$container->setDefinition(TesterExtension::SUITE_TESTER_ID, $definition);
$definition = new Definition(RerunTotalStatistics::class);
$container->setDefinition('output.pretty.statistics', $definition);
}
}
/**
@ -121,6 +159,9 @@ class Extension implements ExtensionInterface
info('Number of seconds that @retry tags will retry for')->
defaultValue(2)->
end()->
scalarNode('is_ci')->
defaultValue(false)->
end()->
arrayNode('ajax_steps')->
defaultValue(array(
'go to',

View File

@ -4,6 +4,7 @@ namespace SilverStripe\BehatExtension;
use Behat\MinkExtension\ServiceContainer\MinkExtension as BaseMinkExtension;
use SilverStripe\BehatExtension\Compiler\MinkExtensionBaseUrlPass;
use SilverStripe\MinkFacebookWebDriver\FacebookFactory;
use Symfony\Component\DependencyInjection\ContainerBuilder;
/**
@ -14,6 +15,12 @@ use Symfony\Component\DependencyInjection\ContainerBuilder;
*/
class MinkExtension extends BaseMinkExtension
{
public function __construct()
{
parent::__construct();
$this->registerDriverFactory(new FacebookFactory());
}
public function process(ContainerBuilder $container)
{
parent::process($container);

175
src/Utility/DebugTools.php Normal file
View File

@ -0,0 +1,175 @@
<?php
namespace SilverStripe\BehatExtension\Utility;
use Behat\Behat\Hook\Scope\AfterScenarioScope;
use Behat\Behat\Hook\Scope\AfterStepScope;
use Behat\Behat\Hook\Scope\StepScope;
use Behat\Testwork\Tester\Result\TestResult;
use Facebook\WebDriver\Exception\WebDriverException;
use SilverStripe\Assets\Filesystem;
use SilverStripe\MinkFacebookWebDriver\FacebookWebDriver;
/**
* Step tools to help debug failing steps
*/
trait DebugTools
{
/**
* @var bool
*/
private $takeScreenshotAfterEveryStep = false;
/**
* @var bool
*/
private $dumpRenderedHtmlAfterEveryStep = false;
/**
* Ensure utilty steps are reset for subsequent scenarios
*
* @AfterScenario
* @param AfterScenarioScope $event
*/
public function resetUtilitiesAfterStep(AfterScenarioScope $event): void
{
$this->takeScreenshotAfterEveryStep = false;
$this->dumpRenderedHtmlAfterEveryStep = false;
}
/**
* Useful step for working out why a behat testing isn't working when running
* the browser headless
* Remove this step from in a feature file once the test is working correct
*
* @Given /^I take a screenshot after every step$/
*/
public function iTakeAScreenshotAfterEveryStep()
{
$this->takeScreenshotAfterEveryStep = true;
}
/**
* Utility function for debugging failing behat tests
* Remove this step from in a feature file once the test is working correct
*
* @Given /^I dump the rendered HTML after every step$/
*/
public function iDumpTheRenderedHtmlAfterEveryStep()
{
$this->dumpRenderedHtmlAfterEveryStep = true;
}
/**
* Take a screenshot when step fails, or
* take a screenshot after every step if the use has specified
* "I take a screenshot after every step"
* Works only with FacebookWebDriver.
*
* @AfterStep
* @param AfterStepScope $event
*/
public function takeScreenshotAfterFailedStep(AfterStepScope $event)
{
// Check failure code
if (!$this->takeScreenshotAfterEveryStep && $event->getTestResult()->getResultCode() !== TestResult::FAILED) {
return;
}
try {
$this->takeScreenshot($event);
} catch (WebDriverException $e) {
$this->logException($e);
}
}
/**
* Dump HTML when step fails.
*
* @AfterStep
* @param AfterStepScope $event
*/
public function dumpHtmlAfterStep(AfterStepScope $event): void
{
// Check failure code
if ($event->getTestResult()->getResultCode() !== TestResult::FAILED && !$this->dumpRenderedHtmlAfterEveryStep) {
return;
}
try {
$this->dumpRenderedHtml($event);
} catch (WebDriverException $e) {
$this->logException($e);
}
}
/**
* Dump rendered HTML to disk
* Useful for seeing the state of a page when writing and debugging feature files
*
* @param StepScope $event
*/
public function dumpRenderedHtml(StepScope $event)
{
$feature = $event->getFeature();
$step = $event->getStep();
$path = $this->prepareScreenshotPath();
if (!$path) {
return;
}
// prefix with zz_ so that it alpha sorts in the directory lower than screenshots which
// will typically be referred to far more often. This is mainly for when you have
// enabled `dumpRenderedHtmlAfterEveryStep`
$path = sprintf('%s/zz_%s_%d.html', $path, basename($feature->getFile() ?? ''), $step->getLine());
$html = $this->getSession()->getPage()->getOuterHtml();
file_put_contents($path ?? '', $html);
$this->logMessage(sprintf('Saving HTML into %s', $path));
}
/**
* Take a nice screenshot
*
* @param StepScope $event
*/
public function takeScreenshot(StepScope $event)
{
// Validate driver
$driver = $this->getSession()->getDriver();
$feature = $event->getFeature();
$step = $event->getStep();
$path = $this->prepareScreenshotPath();
if (!$path) {
return;
}
$path = sprintf('%s/%s_%d.png', $path, basename($feature->getFile() ?? ''), $step->getLine());
$screenshot = $driver->getScreenshot();
file_put_contents($path ?? '', $screenshot);
$this->logMessage(sprintf('Saving screenshot into %s', $path));
}
/**
* Ensure the screenshots path is created
*/
private function prepareScreenshotPath()
{
// Check paths are configured
$path = $this->getMainContext()->getScreenshotPath();
if (!$path) {
$this->logMessage('ScreenShots path not configured: skipping');
return;
}
Filesystem::makeFolder($path);
$path = realpath($path ?? '');
if (!file_exists($path ?? '')) {
$this->logMessage(sprintf('"%s" is not valid directory and failed to create it', $path));
return;
}
if (file_exists($path ?? '') && !is_dir($path ?? '')) {
$this->logMessage(sprintf('"%s" is not valid directory', $path));
return;
}
if (file_exists($path ?? '') && !is_writable($path ?? '')) {
$this->logMessage(sprintf('"%s" directory is not writable', $path));
return;
}
return $path;
}
}

View File

@ -0,0 +1,82 @@
<?php
namespace SilverStripe\BehatExtension\Utility;
use Behat\Testwork\Environment\Environment;
use Behat\Testwork\Specification\SpecificationIterator;
use Behat\Testwork\Tester\Result\IntegerTestResult;
use Behat\Testwork\Tester\Result\TestResult;
use Behat\Testwork\Tester\Result\TestResults;
use Behat\Testwork\Tester\Result\TestWithSetupResult;
use Behat\Testwork\Tester\Setup\SuccessfulSetup;
use Behat\Testwork\Tester\Setup\SuccessfulTeardown;
use Behat\Testwork\Tester\SpecificationTester;
use Behat\Testwork\Tester\SuiteTester;
/**
* Copy paste of Behat\Testwork\Tester\Runtime\RuntimeSuiteTester which is a final class
*
* Modified so that it reruns failed features
*/
class RerunRuntimeSuiteTester implements SuiteTester
{
/**
* @var SpecificationTester
*/
private $specTester;
/**
* Initializes tester.
*
* @param SpecificationTester $specTester
*/
public function __construct(SpecificationTester $specTester)
{
$this->specTester = $specTester;
}
/**
* {@inheritdoc}
*/
public function setUp(Environment $env, SpecificationIterator $iterator, $skip)
{
return new SuccessfulSetup();
}
/**
* {@inheritdoc}
*/
public function test(Environment $env, SpecificationIterator $iterator, $skip = false)
{
$results = array();
foreach ($iterator as $specification) {
$setup = $this->specTester->setUp($env, $specification, $skip);
$localSkip = !$setup->isSuccessful() || $skip;
$testResult = $this->specTester->test($env, $specification, $localSkip);
$teardown = $this->specTester->tearDown($env, $specification, $localSkip, $testResult);
// start modifications here
if (!$testResult->isPassed()) {
file_put_contents('php://stdout', 'Retrying specification' . PHP_EOL);
$setup = $this->specTester->setUp($env, $specification, $skip);
$localSkip = !$setup->isSuccessful() || $skip;
$testResult = $this->specTester->test($env, $specification, $localSkip);
$teardown = $this->specTester->tearDown($env, $specification, $localSkip, $testResult);
}
// end modifications here
$integerResult = new IntegerTestResult($testResult->getResultCode());
$results[] = new TestWithSetupResult($setup, $integerResult, $teardown);
}
return new TestResults($results);
}
/**
* {@inheritdoc}
*/
public function tearDown(Environment $env, SpecificationIterator $iterator, $skip, TestResult $result)
{
return new SuccessfulTeardown();
}
}

View File

@ -0,0 +1,314 @@
<?php
namespace SilverStripe\BehatExtension\Utility;
use Behat\Behat\Tester\Result\StepResult;
use Behat\Testwork\Counter\Memory;
use Behat\Testwork\Counter\Timer;
use Behat\Testwork\Tester\Result\TestResult;
use Behat\Testwork\Tester\Result\TestResults;
use Behat\Behat\Output\Statistics\Statistics;
use Behat\Behat\Output\Statistics\ScenarioStat;
use Behat\Behat\Output\Statistics\StepStat;
use Behat\Behat\Output\Statistics\HookStat;
/**
* Copy paste of Behat\Behat\Output\Statistics\TotalStatistics which is a final class
*
* Modified to remove duplicated stats from reruns
*/
class RerunTotalStatistics implements Statistics
{
/**
* @var Timer
*/
private $timer;
/**
* @var Memory
*/
private $memory;
/**
* @var array
*/
private $scenarioCounters = array();
/**
* @var array
*/
private $stepCounters = array();
/**
* @var ScenarioStat[]
*/
private $failedScenarioStats = array();
/**
* @var ScenarioStat[]
*/
private $skippedScenarioStats = array();
/**
* @var StepStat[]
*/
private $failedStepStats = array();
/**
* @var StepStat[]
*/
private $pendingStepStats = array();
/**
* @var HookStat[]
*/
private $failedHookStats = array();
// start modifications here
/**
* @var StepStat[]
*/
private $passedStepStats = array();
// end modifications here
/**
* Initializes statistics.
*/
public function __construct()
{
$this->resetAllCounters();
$this->timer = new Timer();
$this->memory = new Memory();
}
public function resetAllCounters()
{
$this->scenarioCounters = $this->stepCounters = array(
TestResult::PASSED => 0,
TestResult::FAILED => 0,
StepResult::UNDEFINED => 0,
TestResult::PENDING => 0,
TestResult::SKIPPED => 0
);
}
/**
* Starts timer.
*/
public function startTimer()
{
$this->timer->start();
}
/**
* Stops timer.
*/
public function stopTimer()
{
$this->timer->stop();
}
/**
* Returns timer object.
*
* @return Timer
*/
public function getTimer()
{
return $this->timer;
}
/**
* Returns memory usage object.
*
* @return Memory
*/
public function getMemory()
{
return $this->memory;
}
/**
* Registers scenario stat.
*
* @param ScenarioStat $stat
*/
public function registerScenarioStat(ScenarioStat $stat)
{
if (TestResults::NO_TESTS === $stat->getResultCode()) {
return;
}
$this->scenarioCounters[$stat->getResultCode()]++;
// start modifications here
if (TestResult::FAILED === $stat->getResultCode()) {
// Ensure that any scenario reruns aren't counted as additional failures
$alreadyHasFailure = false;
foreach ($this->failedScenarioStats as $failedStat) {
if ($failedStat->getPath() === $stat->getPath()) {
$alreadyHasFailure = true;
break;
}
}
if (!$alreadyHasFailure) {
$this->failedScenarioStats[] = $stat;
} else {
$this->scenarioCounters[TestResult::FAILED]--;
}
}
if (TestResult::PASSED == $stat->getResultCode()) {
// Remove the scenario from the failed scenarios list if it passes on rerun
$newFailedScenarioStats = [];
foreach ($this->failedScenarioStats as $failedStat) {
if ($failedStat->getPath() !== $stat->getPath()) {
$newFailedScenarioStats[] = $failedStat;
} else {
$this->scenarioCounters[TestResult::FAILED]--;
}
}
$this->failedScenarioStats = $newFailedScenarioStats;
}
// end modifications here
if (TestResult::SKIPPED === $stat->getResultCode()) {
$this->skippedScenarioStats[] = $stat;
}
}
/**
* Registers step stat.
*
* @param StepStat $stat
*/
public function registerStepStat(StepStat $stat)
{
$this->stepCounters[$stat->getResultCode()]++;
// start modifications here
if (TestResult::FAILED === $stat->getResultCode()) {
// Ensure that any scenario reruns don't double count step failures
$alreadyHasFailure = false;
foreach ($this->failedStepStats as $failedStat) {
if ($failedStat->getPath() === $stat->getPath()) {
$alreadyHasFailure = true;
break;
}
}
if (!$alreadyHasFailure) {
$this->failedStepStats[] = $stat;
} else {
$this->stepCounters[TestResult::FAILED]--;
}
}
if (TestResult::PASSED == $stat->getResultCode()) {
// Remove any duplicate passes on scenario rerun
$alreadyHasSuccess = false;
foreach ($this->passedStepStats as $passedStat) {
if ($passedStat->getPath() === $stat->getPath()) {
$alreadyHasSuccess = true;
break;
}
}
if (!$alreadyHasSuccess) {
$this->passedStepStats[] = $stat;
} else {
$this->stepCounters[TestResult::PASSED]--;
}
// Remove the step from the failed steps list if it passes on scenario rerun
$newFailedStepStats = [];
foreach ($this->failedStepStats as $failedStat) {
if ($failedStat->getPath() !== $stat->getPath()) {
$newFailedStepStats[] = $failedStat;
} else {
$this->stepCounters[TestResult::FAILED]--;
}
}
$this->failedStepStats = $newFailedStepStats;
}
// end modifications here
if (TestResult::PENDING === $stat->getResultCode()) {
$this->pendingStepStats[] = $stat;
}
}
/**
* Registers hook stat.
*
* @param HookStat $stat
*/
public function registerHookStat(HookStat $stat)
{
if ($stat->isSuccessful()) {
return;
}
$this->failedHookStats[] = $stat;
}
/**
* Returns counters for different scenario result codes.
*
* @return array[]
*/
public function getScenarioStatCounts()
{
return $this->scenarioCounters;
}
/**
* Returns skipped scenario stats.
*
* @return ScenarioStat[]
*/
public function getSkippedScenarios()
{
return $this->skippedScenarioStats;
}
/**
* Returns failed scenario stats.
*
* @return ScenarioStat[]
*/
public function getFailedScenarios()
{
return $this->failedScenarioStats;
}
/**
* Returns counters for different step result codes.
*
* @return array[]
*/
public function getStepStatCounts()
{
return $this->stepCounters;
}
/**
* Returns failed step stats.
*
* @return StepStat[]
*/
public function getFailedSteps()
{
return $this->failedStepStats;
}
/**
* Returns pending step stats.
*
* @return StepStat[]
*/
public function getPendingSteps()
{
return $this->pendingStepStats;
}
/**
* Returns failed hook stats.
*
* @return HookStat[]
*/
public function getFailedHookStats()
{
return $this->failedHookStats;
}
}

View File

@ -112,10 +112,10 @@ class RetryableCallHandler implements CallHandler
// Determine whether to call with retries
if ($retry) {
$return = $this->retryThrowable(function () use ($callable, $arguments) {
return call_user_func_array($callable, $arguments);
return call_user_func_array($callable, $arguments ?? []);
}, $this->retrySeconds);
} else {
$return = call_user_func_array($callable, $arguments);
$return = call_user_func_array($callable, $arguments ?? []);
}
} catch (Exception $caught) {
$exception = $caught;
@ -144,7 +144,7 @@ class RetryableCallHandler implements CallHandler
private function startErrorAndOutputBuffering(Call $call)
{
$errorReporting = $call->getErrorReportingLevel() ? : $this->errorReportingLevel;
set_error_handler(array($this, 'handleError'), $errorReporting);
set_error_handler(array($this, 'handleError'), $errorReporting ?? 0);
$this->obStarted = ob_start();
}

View File

@ -2,11 +2,14 @@
namespace SilverStripe\BehatExtension\Utility;
use Behat\Behat\Hook\Scope\ScenarioScope;
use Behat\Behat\Hook\Scope\StepScope;
use Behat\Gherkin\Node\FeatureNode;
use Behat\Gherkin\Node\NodeInterface;
use Behat\Gherkin\Node\ScenarioInterface;
use Behat\Gherkin\Node\TaggedNodeInterface;
use \Exception;
use InvalidArgumentException;
/**
* Helpers for working with steps
@ -65,22 +68,35 @@ trait StepHelper
/**
* Check if a step has a given tag
*
* @param StepScope $event
* @param StepScope|ScenarioScope $event
* @param string $tag
* @return bool
*/
protected function stepHasTag(StepScope $event, $tag)
protected function stepHasTag($event, $tag)
{
// Check feature
$feature = $event->getFeature();
if ($feature && $feature->hasTag($tag)) {
return true;
$checks = [];
if ($event instanceof StepScope) {
$checks[] = $feature = $event->getFeature();
$checks[] = $this->getStepScenario($feature, $event->getStep());
} elseif ($event instanceof ScenarioScope) {
$checks[] = $event->getFeature();
$checks[] = $event->getScenario();
} else {
throw new InvalidArgumentException(sprintf(
'%s expected an instance of either %s or %s. Got %s instead',
__METHOD__,
StepScope::class,
ScenarioScope::class,
is_object($event) ? sprintf('an instance of %s', get_class($event)) : gettype($event)
));
}
// Check scenario
$scenario = $this->getStepScenario($feature, $event->getStep());
if ($scenario && $scenario->hasTag($tag)) {
return true;
foreach ($checks as $check) {
if ($check instanceof TaggedNodeInterface && $check->hasTag($tag)) {
return true;
}
}
return false;
}
}

View File

@ -38,7 +38,7 @@ class TestMailer extends BaseTestMailer
{
$matches = $this->findEmails($to, $from, $subject, $content);
//got the count of matches emails
$emailCount = count($matches);
$emailCount = count($matches ?? []);
//get the last(latest) one
return $matches ? $matches[$emailCount-1] : null;
}
@ -70,7 +70,7 @@ class TestMailer extends BaseTestMailer
$value = (isset($args[$i])) ? $args[$i] : null;
if ($value) {
if ($value[0] == '/') {
$matched = preg_match($value, $email->$field);
$matched = preg_match($value ?? '', $email->$field ?? '');
} else {
$matched = ($value == $email->$field);
}
@ -93,7 +93,7 @@ class TestMailer extends BaseTestMailer
if (!isset($state->emails)) {
$state->emails = array();
}
$state->emails[] = array_filter($data);
$state->emails[] = array_filter($data ?? []);
$this->testSessionEnvironment->applyState($state);
}
}

View File

@ -7,4 +7,4 @@ default:
selenium2:
browser: firefox
SilverStripe\BehatExtension\Extension:
screenshot_path: %paths.base%/artifacts/screenshots
screenshot_path: '%paths.base%/artifacts/screenshots'

View File

@ -8,37 +8,27 @@ use Behat\Mink\Session;
use Behat\Mink\Mink;
use Behat\Mink\Driver\DriverInterface;
use Behat\Mink\Element\Element;
use PHPUnit\Framework\MockObject\MockObject;
use SilverStripe\BehatExtension\Tests\SilverStripeContextTest\FeatureContext;
use SilverStripe\Dev\SapphireTest;
class SilverStripeContextTest extends \PHPUnit_Framework_TestCase
class SilverStripeContextTest extends SapphireTest
{
protected $backupGlobals = false;
public static function setUpBeforeClass()
{
// Bootstrap test environment
parent::setUpBeforeClass();
SapphireTest::start();
}
/**
* @expectedException \LogicException
* @expectedExceptionMessage Cannot find 'region_map' in the behat.yml
*/
public function testGetRegionObjThrowsExceptionOnUnknownSelector()
{
$this->expectException(\LogicException::class);
$this->expectExceptionMessage("Cannot find 'region_map' in the behat.yml");
$context = $this->getContextMock();
$context->getRegionObj('.unknown');
}
/**
* @expectedException \LogicException
* @expectedExceptionMessage Cannot find the specified region in the behat.yml
*/
public function testGetRegionObjThrowsExceptionOnUnknownRegion()
{
$this->expectException(\LogicException::class);
$this->expectExceptionMessage("Cannot find the specified region in the behat.yml");
$context = $this->getContextMock();
$context->setRegionMap(array('MyRegion' => '.my-region'));
$context->getRegionObj('.unknown');
@ -99,7 +89,7 @@ class SilverStripeContextTest extends \PHPUnit_Framework_TestCase
}
/**
* @return Element|\PHPUnit_Framework_MockObject_MockObject
* @return Element|MockObject
*/
protected function getElementMock()
{