diff options
author | Roeland Jago Douma <rullzer@users.noreply.github.com> | 2017-04-21 20:53:32 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2017-04-21 20:53:32 +0200 |
commit | eaa6f766e694f08e899c9469f668135c5d7b0c34 (patch) | |
tree | ea64cf5715f41ad8f05f1fb0149af0ff16f15035 /tests | |
parent | 867b3ee234b91ce5fbbbf3b81a5bcce59a0179a3 (diff) | |
parent | e970b5261fd0ae126db788d514ab4c7770688356 (diff) | |
download | nextcloud-server-eaa6f766e694f08e899c9469f668135c5d7b0c34.tar.gz nextcloud-server-eaa6f766e694f08e899c9469f668135c5d7b0c34.zip |
Merge pull request #4208 from danxuliu/add-basic-acceptance-test-system
Add basic acceptance test system
Diffstat (limited to 'tests')
23 files changed, 2108 insertions, 0 deletions
diff --git a/tests/acceptance/composer.json b/tests/acceptance/composer.json new file mode 100644 index 00000000000..87b6ba4a22c --- /dev/null +++ b/tests/acceptance/composer.json @@ -0,0 +1,14 @@ +{ + "require-dev": { + "behat/behat": "^3.0", + "behat/mink": "^1.5", + "behat/mink-extension": "*", + "behat/mink-selenium2-driver": "*", + "phpunit/phpunit": "~4.6" + }, + "autoload": { + "psr-4": { + "": "features/core" + } + } +} diff --git a/tests/acceptance/config/behat.yml b/tests/acceptance/config/behat.yml new file mode 100644 index 00000000000..6c3d9e4a7b9 --- /dev/null +++ b/tests/acceptance/config/behat.yml @@ -0,0 +1,26 @@ +default: + autoload: + '': %paths.base%/../features/bootstrap + suites: + default: + paths: + - %paths.base%/../features + contexts: + - ActorContext + - NextcloudTestServerContext + + - FeatureContext + - FilesAppContext + - LoginPageContext + - NotificationContext + - SettingsMenuContext + - UsersSettingsContext + extensions: + Behat\MinkExtension: + sessions: + default: + selenium2: ~ + John: + selenium2: ~ + Jane: + selenium2: ~ diff --git a/tests/acceptance/features/access-levels.feature b/tests/acceptance/features/access-levels.feature new file mode 100644 index 00000000000..57998899a57 --- /dev/null +++ b/tests/acceptance/features/access-levels.feature @@ -0,0 +1,21 @@ +Feature: access-levels + + Scenario: regular users can not see admin-level items in the Settings menu + Given I am logged in + When I open the Settings menu + Then I see that the Settings menu is shown + And I see that the "Personal" item in the Settings menu is shown + And I see that the "Admin" item in the Settings menu is not shown + And I see that the "Users" item in the Settings menu is not shown + And I see that the "Help" item in the Settings menu is shown + And I see that the "Log out" item in the Settings menu is shown + + Scenario: admin users can see admin-level items in the Settings menu + Given I am logged in as the admin + When I open the Settings menu + Then I see that the Settings menu is shown + And I see that the "Personal" item in the Settings menu is shown + And I see that the "Admin" item in the Settings menu is shown + And I see that the "Users" item in the Settings menu is shown + And I see that the "Help" item in the Settings menu is shown + And I see that the "Log out" item in the Settings menu is shown diff --git a/tests/acceptance/features/bootstrap/FeatureContext.php b/tests/acceptance/features/bootstrap/FeatureContext.php new file mode 100644 index 00000000000..a125ea01ccc --- /dev/null +++ b/tests/acceptance/features/bootstrap/FeatureContext.php @@ -0,0 +1,37 @@ +<?php + +/** + * + * @copyright Copyright (c) 2017, Daniel Calviño Sánchez (danxuliu@gmail.com) + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ + +use Behat\Behat\Context\Context; + +class FeatureContext implements Context, ActorAwareInterface { + + use ActorAware; + + /** + * @When I visit the Home page + */ + public function iVisitTheHomePage() { + $this->actor->getSession()->visit($this->actor->locatePath("/")); + } + +} diff --git a/tests/acceptance/features/bootstrap/FilesAppContext.php b/tests/acceptance/features/bootstrap/FilesAppContext.php new file mode 100644 index 00000000000..9702e64b552 --- /dev/null +++ b/tests/acceptance/features/bootstrap/FilesAppContext.php @@ -0,0 +1,39 @@ +<?php + +/** + * + * @copyright Copyright (c) 2017, Daniel Calviño Sánchez (danxuliu@gmail.com) + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ + +use Behat\Behat\Context\Context; + +class FilesAppContext implements Context, ActorAwareInterface { + + use ActorAware; + + /** + * @Then I see that the current page is the Files app + */ + public function iSeeThatTheCurrentPageIsTheFilesApp() { + PHPUnit_Framework_Assert::assertStringStartsWith( + $this->actor->locatePath("/apps/files/"), + $this->actor->getSession()->getCurrentUrl()); + } + +} diff --git a/tests/acceptance/features/bootstrap/LoginPageContext.php b/tests/acceptance/features/bootstrap/LoginPageContext.php new file mode 100644 index 00000000000..4b0672f652c --- /dev/null +++ b/tests/acceptance/features/bootstrap/LoginPageContext.php @@ -0,0 +1,137 @@ +<?php + +/** + * + * @copyright Copyright (c) 2017, Daniel Calviño Sánchez (danxuliu@gmail.com) + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ + +use Behat\Behat\Context\Context; +use Behat\Behat\Hook\Scope\BeforeScenarioScope; + +class LoginPageContext implements Context, ActorAwareInterface { + + use ActorAware; + + /** + * @var FeatureContext + */ + private $featureContext; + + /** + * @var FilesAppContext + */ + private $filesAppContext; + + /** + * @return Locator + */ + public static function userNameField() { + return Locator::forThe()->field("user")-> + describedAs("User name field in Login page"); + } + + /** + * @return Locator + */ + public static function passwordField() { + return Locator::forThe()->field("password")-> + describedAs("Password field in Login page"); + } + + /** + * @return Locator + */ + public static function loginButton() { + return Locator::forThe()->id("submit")-> + describedAs("Login button in Login page"); + } + + /** + * @return Locator + */ + public static function wrongPasswordMessage() { + return Locator::forThe()->content("Wrong password. Reset it?")-> + describedAs("Wrong password message in Login page"); + } + + /** + * @When I log in with user :user and password :password + */ + public function iLogInWithUserAndPassword($user, $password) { + $this->actor->find(self::userNameField(), 10)->setValue($user); + $this->actor->find(self::passwordField())->setValue($password); + $this->actor->find(self::loginButton())->click(); + } + + /** + * @Then I see that the current page is the Login page + */ + public function iSeeThatTheCurrentPageIsTheLoginPage() { + PHPUnit_Framework_Assert::assertStringStartsWith( + $this->actor->locatePath("/login"), + $this->actor->getSession()->getCurrentUrl()); + } + + /** + * @Then I see that a wrong password message is shown + */ + public function iSeeThatAWrongPasswordMessageIsShown() { + PHPUnit_Framework_Assert::assertTrue( + $this->actor->find(self::wrongPasswordMessage(), 10)->isVisible()); + } + + /** + * @BeforeScenario + */ + public function getOtherRequiredSiblingContexts(BeforeScenarioScope $scope) { + $environment = $scope->getEnvironment(); + + $this->featureContext = $environment->getContext("FeatureContext"); + $this->filesAppContext = $environment->getContext("FilesAppContext"); + } + + /** + * @Given I am logged in + */ + public function iAmLoggedIn() { + $this->featureContext->iVisitTheHomePage(); + $this->iLogInWithUserAndPassword("user0", "123456acb"); + $this->filesAppContext->iSeeThatTheCurrentPageIsTheFilesApp(); + } + + /** + * @Given I am logged in as the admin + */ + public function iAmLoggedInAsTheAdmin() { + $this->featureContext->iVisitTheHomePage(); + $this->iLogInWithUserAndPassword("admin", "admin"); + $this->filesAppContext->iSeeThatTheCurrentPageIsTheFilesApp(); + } + + /** + * @Given I can not log in with user :user and password :password + */ + public function iCanNotLogInWithUserAndPassword($user, $password) { + $this->featureContext->iVisitTheHomePage(); + $this->iLogInWithUserAndPassword($user, $password); + $this->iSeeThatTheCurrentPageIsTheLoginPage(); + $this->iSeeThatAWrongPasswordMessageIsShown(); + } + +} diff --git a/tests/acceptance/features/bootstrap/NotificationContext.php b/tests/acceptance/features/bootstrap/NotificationContext.php new file mode 100644 index 00000000000..f8b784e2465 --- /dev/null +++ b/tests/acceptance/features/bootstrap/NotificationContext.php @@ -0,0 +1,54 @@ +<?php + +/** + * + * @copyright Copyright (c) 2017, Daniel Calviño Sánchez (danxuliu@gmail.com) + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ + +use Behat\Behat\Context\Context; + +class NotificationContext implements Context, ActorAwareInterface { + + use ActorAware; + + /** + * @return Locator + */ + public static function notificationMessage($message) { + return Locator::forThe()->content($message)->descendantOf(self::notificationContainer())-> + describedAs("$message notification"); + } + + /** + * @return Locator + */ + private static function notificationContainer() { + return Locator::forThe()->id("notification-container")-> + describedAs("Notification container"); + } + + /** + * @Then I see that the :message notification is shown + */ + public function iSeeThatTheNotificationIsShown($message) { + PHPUnit_Framework_Assert::assertTrue($this->actor->find( + self::notificationMessage($message), 10)->isVisible()); + } + +} diff --git a/tests/acceptance/features/bootstrap/SettingsMenuContext.php b/tests/acceptance/features/bootstrap/SettingsMenuContext.php new file mode 100644 index 00000000000..9ce8df4caef --- /dev/null +++ b/tests/acceptance/features/bootstrap/SettingsMenuContext.php @@ -0,0 +1,122 @@ +<?php + +/** + * + * @copyright Copyright (c) 2017, Daniel Calviño Sánchez (danxuliu@gmail.com) + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ + +use Behat\Behat\Context\Context; + +class SettingsMenuContext implements Context, ActorAwareInterface { + + use ActorAware; + + /** + * @return Locator + */ + public static function settingsMenuButton() { + return Locator::forThe()->xpath("//*[@id = 'header']//*[@id = 'settings']")-> + describedAs("Settings menu button"); + } + + /** + * @return Locator + */ + public static function settingsMenu() { + return Locator::forThe()->id("expanddiv")->descendantOf(self::settingsMenuButton())-> + describedAs("Settings menu"); + } + + /** + * @return Locator + */ + public static function usersMenuItem() { + return self::menuItemFor("Users"); + } + + /** + * @return Locator + */ + public static function logOutMenuItem() { + return self::menuItemFor("Log out"); + } + + /** + * @return Locator + */ + private static function menuItemFor($itemText) { + return Locator::forThe()->content($itemText)->descendantOf(self::settingsMenu())-> + describedAs($itemText . " item in Settings menu"); + } + + /** + * @When I open the Settings menu + */ + public function iOpenTheSettingsMenu() { + $this->actor->find(self::settingsMenuButton(), 10)->click(); + } + + /** + * @When I open the User settings + */ + public function iOpenTheUserSettings() { + $this->iOpenTheSettingsMenu(); + + $this->actor->find(self::usersMenuItem(), 2)->click(); + } + + /** + * @When I log out + */ + public function iLogOut() { + $this->iOpenTheSettingsMenu(); + + $this->actor->find(self::logOutMenuItem(), 2)->click(); + } + + /** + * @Then I see that the Settings menu is shown + */ + public function iSeeThatTheSettingsMenuIsShown() { + PHPUnit_Framework_Assert::assertTrue( + $this->actor->find(self::settingsMenu(), 10)->isVisible()); + } + + /** + * @Then I see that the :itemText item in the Settings menu is shown + */ + public function iSeeThatTheItemInTheSettingsMenuIsShown($itemText) { + PHPUnit_Framework_Assert::assertTrue( + $this->actor->find(self::menuItemFor($itemText), 10)->isVisible()); + } + + /** + * @Then I see that the :itemText item in the Settings menu is not shown + */ + public function iSeeThatTheItemInTheSettingsMenuIsNotShown($itemText) { + $this->iSeeThatTheSettingsMenuIsShown(); + + try { + PHPUnit_Framework_Assert::assertFalse( + $this->actor->find(self::menuItemFor($itemText))->isVisible()); + } catch (NoSuchElementException $exception) { + } + } + +} diff --git a/tests/acceptance/features/bootstrap/UsersSettingsContext.php b/tests/acceptance/features/bootstrap/UsersSettingsContext.php new file mode 100644 index 00000000000..93ab7246eb6 --- /dev/null +++ b/tests/acceptance/features/bootstrap/UsersSettingsContext.php @@ -0,0 +1,102 @@ +<?php + +/** + * + * @copyright Copyright (c) 2017, Daniel Calviño Sánchez (danxuliu@gmail.com) + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ + +use Behat\Behat\Context\Context; + +class UsersSettingsContext implements Context, ActorAwareInterface { + + use ActorAware; + + /** + * @return Locator + */ + public static function userNameFieldForNewUser() { + return Locator::forThe()->field("newusername")-> + describedAs("User name field for new user in Users Settings"); + } + + /** + * @return Locator + */ + public static function passwordFieldForNewUser() { + return Locator::forThe()->field("newuserpassword")-> + describedAs("Password field for new user in Users Settings"); + } + + /** + * @return Locator + */ + public static function createNewUserButton() { + return Locator::forThe()->xpath("//form[@id = 'newuser']//input[@type = 'submit']")-> + describedAs("Create user button in Users Settings"); + } + + /** + * @return Locator + */ + public static function rowForUser($user) { + return Locator::forThe()->xpath("//table[@id = 'userlist']//th[normalize-space() = '$user']/..")-> + describedAs("Row for user $user in Users Settings"); + } + + /** + * @return Locator + */ + public static function passwordCellForUser($user) { + return Locator::forThe()->css(".password")->descendantOf(self::rowForUser($user))-> + describedAs("Password cell for user $user in Users Settings"); + } + + /** + * @return Locator + */ + public static function passwordInputForUser($user) { + return Locator::forThe()->css("input")->descendantOf(self::passwordCellForUser($user))-> + describedAs("Password input for user $user in Users Settings"); + } + + /** + * @When I create user :user with password :password + */ + public function iCreateUserWithPassword($user, $password) { + $this->actor->find(self::userNameFieldForNewUser(), 10)->setValue($user); + $this->actor->find(self::passwordFieldForNewUser())->setValue($password); + $this->actor->find(self::createNewUserButton())->click(); + } + + /** + * @When I set the password for :user to :password + */ + public function iSetThePasswordForUserTo($user, $password) { + $this->actor->find(self::passwordCellForUser($user), 10)->click(); + $this->actor->find(self::passwordInputForUser($user), 2)->setValue($password . "\r"); + } + + /** + * @Then I see that the list of users contains the user :user + */ + public function iSeeThatTheListOfUsersContainsTheUser($user) { + PHPUnit_Framework_Assert::assertNotNull($this->actor->find(self::rowForUser($user), 10)); + } + +} diff --git a/tests/acceptance/features/core/Actor.php b/tests/acceptance/features/core/Actor.php new file mode 100644 index 00000000000..a27e8e6a015 --- /dev/null +++ b/tests/acceptance/features/core/Actor.php @@ -0,0 +1,224 @@ +<?php + +/** + * + * @copyright Copyright (c) 2017, Daniel Calviño Sánchez (danxuliu@gmail.com) + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ + +/** + * An actor in a test scenario. + * + * Every Actor object is intended to be used only in a single test scenario. + * An Actor can control its web browser thanks to the Mink Session received when + * it was created, so in each scenario each Actor must have its own Mink + * Session; the same Mink Session can be used by different Actors in different + * scenarios, but never by different Actors in the same scenario. + * + * The test servers used in an scenario can change between different test runs, + * so an Actor stores the base URL for the current test server being used; in + * most cases the tests are specified using relative paths that can be converted + * to the appropriate absolute URL using locatePath() in the step + * implementation. + * + * An Actor can find elements in its Mink Session using its find() method; it is + * a wrapper over the find() method provided by Mink that extends it with + * several features: the element can be looked for based on a Locator object, an + * exception is thrown if the element is not found, and, optionally, it is + * possible to try again to find the element several times before giving up. + * + * The amount of time to wait before giving up is specified in each call to + * find(). However, a general multiplier to be applied to every timeout can be + * set using setFindTimeoutMultiplier(); this makes possible to retry longer + * before giving up without modifying the tests themselves. Note that the + * multiplier affects the timeout, but not the timeout step; the rate at which + * find() will try again to find the element does not change. + */ +class Actor { + + /** + * @var \Behat\Mink\Session + */ + private $session; + + /** + * @var string + */ + private $baseUrl; + + /** + * @var float + */ + private $findTimeoutMultiplier; + + /** + * Creates a new Actor. + * + * @param \Behat\Mink\Session $session the Mink Session used to control its + * web browser. + * @param string $baseUrl the base URL used when solving relative URLs. + */ + public function __construct(\Behat\Mink\Session $session, $baseUrl) { + $this->session = $session; + $this->baseUrl = $baseUrl; + $this->findTimeoutMultiplier = 1; + } + + /** + * Sets the base URL. + * + * @param string $baseUrl the base URL used when solving relative URLs. + */ + public function setBaseUrl($baseUrl) { + $this->baseUrl = $baseUrl; + } + + /** + * Sets the multiplier for find timeouts. + * + * @param float $findTimeoutMultiplier the multiplier to apply to find + * timeouts. + */ + public function setFindTimeoutMultiplier($findTimeoutMultiplier) { + $this->findTimeoutMultiplier = $findTimeoutMultiplier; + } + + /** + * Returns the Mink Session used to control its web browser. + * + * @return \Behat\Mink\Session the Mink Session used to control its web + * browser. + */ + public function getSession() { + return $this->session; + } + + /** + * Returns the full path for the given relative path based on the base URL. + * + * @param string relativePath the relative path. + * @return string the full path. + */ + public function locatePath($relativePath) { + return $this->baseUrl . $relativePath; + } + + /** + * Finds an element in the Mink Session of this Actor. + * + * The given element locator is relative to its ancestor (either another + * locator or an actual element); if it has no ancestor then the base + * document element is used. + * + * Sometimes an element may not be found simply because it has not appeared + * yet; for those cases this method supports trying again to find the + * element several times before giving up. The timeout parameter controls + * how much time to wait, at most, to find the element; the timeoutStep + * parameter controls how much time to wait before trying again to find the + * element. If ancestor locators need to be found the timeout is applied + * individually to each one, that is, if the timeout is 10 seconds the + * method will wait up to 10 seconds to find the ancestor of the ancestor + * and, then, up to 10 seconds to find the ancestor and, then, up to 10 + * seconds to find the element. By default the timeout is 0, so the element + * and its ancestor will be looked for just once; the default time to wait + * before retrying is half a second. If the timeout is not 0 it will be + * affected by the multiplier set using setFindTimeoutMultiplier(), if any. + * + * In any case, if the element, or its ancestors, can not be found a + * NoSuchElementException is thrown. + * + * @param Locator $elementLocator the locator for the element. + * @param float $timeout the number of seconds (decimals allowed) to wait at + * most for the element to appear. + * @param float $timeoutStep the number of seconds (decimals allowed) to + * wait before trying to find the element again. + * @return \Behat\Mink\Element\Element the element found. + * @throws NoSuchElementException if the element, or its ancestor, can not + * be found. + */ + public function find($elementLocator, $timeout = 0, $timeoutStep = 0.5) { + $timeout = $timeout * $this->findTimeoutMultiplier; + + $element = null; + $selector = $elementLocator->getSelector(); + $locator = $elementLocator->getLocator(); + $ancestorElement = $this->findAncestorElement($elementLocator, $timeout, $timeoutStep); + + $findCallback = function() use (&$element, $selector, $locator, $ancestorElement) { + $element = $ancestorElement->find($selector, $locator); + + return $element !== null; + }; + if (!Utils::waitFor($findCallback, $timeout, $timeoutStep)) { + $message = $elementLocator->getDescription() . " could not be found"; + if ($timeout > 0) { + $message = $message . " after $timeout seconds"; + } + throw new NoSuchElementException($message); + } + + return $element; + } + + /** + * Returns the ancestor element from which the given locator will be looked + * for. + * + * If the ancestor of the given locator is another locator the element for + * the ancestor locator is found and returned. If the ancestor of the given + * locator is already an element that element is the one returned. If the + * given locator has no ancestor then the base document element is returned. + * + * The timeout is used only when finding the element for the ancestor + * locator; if the timeout expires a NoSuchElementException is thrown. + * + * @param Locator $elementLocator the locator for the element to get its + * ancestor. + * @param float $timeout the number of seconds (decimals allowed) to wait at + * most for the ancestor element to appear. + * @param float $timeoutStep the number of seconds (decimals allowed) to + * wait before trying to find the ancestor element again. + * @return \Behat\Mink\Element\Element the ancestor element found. + * @throws NoSuchElementException if the ancestor element can not be found. + */ + private function findAncestorElement($elementLocator, $timeout, $timeoutStep) { + $ancestorElement = $elementLocator->getAncestor(); + if ($ancestorElement instanceof Locator) { + try { + $ancestorElement = $this->find($ancestorElement, $timeout, $timeoutStep); + } catch (NoSuchElementException $exception) { + // Little hack to show the stack of ancestor elements that could + // not be found, as Behat only shows the message of the last + // exception in the chain. + $message = $exception->getMessage() . "\n" . + $elementLocator->getDescription() . " could not be found"; + if ($timeout > 0) { + $message = $message . " after $timeout seconds"; + } + throw new NoSuchElementException($message, $exception); + } + } + + if ($ancestorElement === null) { + $ancestorElement = $this->getSession()->getPage(); + } + + return $ancestorElement; + } + +} diff --git a/tests/acceptance/features/core/ActorAware.php b/tests/acceptance/features/core/ActorAware.php new file mode 100644 index 00000000000..f1d355c1b0e --- /dev/null +++ b/tests/acceptance/features/core/ActorAware.php @@ -0,0 +1,38 @@ +<?php + +/** + * + * @copyright Copyright (c) 2017, Daniel Calviño Sánchez (danxuliu@gmail.com) + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ + +trait ActorAware { + + /** + * @var Actor + */ + private $actor; + + /** + * @param Actor $actor + */ + public function setCurrentActor(Actor $actor) { + $this->actor = $actor; + } + +} diff --git a/tests/acceptance/features/core/ActorAwareInterface.php b/tests/acceptance/features/core/ActorAwareInterface.php new file mode 100644 index 00000000000..9363bc3e607 --- /dev/null +++ b/tests/acceptance/features/core/ActorAwareInterface.php @@ -0,0 +1,31 @@ +<?php + +/** + * + * @copyright Copyright (c) 2017, Daniel Calviño Sánchez (danxuliu@gmail.com) + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ + +interface ActorAwareInterface { + + /** + * @param Actor $actor + */ + public function setCurrentActor(Actor $actor); + +} diff --git a/tests/acceptance/features/core/ActorContext.php b/tests/acceptance/features/core/ActorContext.php new file mode 100644 index 00000000000..9667ef2f01c --- /dev/null +++ b/tests/acceptance/features/core/ActorContext.php @@ -0,0 +1,148 @@ +<?php + +/** + * + * @copyright Copyright (c) 2017, Daniel Calviño Sánchez (danxuliu@gmail.com) + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ + +use Behat\Behat\Hook\Scope\BeforeStepScope; +use Behat\MinkExtension\Context\RawMinkContext; + +/** + * Behat context to set the actor used in sibling contexts. + * + * This helper context provides a step definition ("I act as XXX") to change the + * current actor of the scenario, which makes possible to use different browser + * sessions in the same scenario. + * + * Sibling contexts that want to have access to the current actor of the + * scenario must implement the ActorAwareInterface; this can be done just by + * using the ActorAware trait. + * + * Besides updating the current actor in sibling contexts the ActorContext also + * propagates its inherited "base_url" Mink parameter to the Actors as needed. + * + * By default no multiplier for the find timeout is set in the Actors. However, + * it can be customized using the "actorFindTimeoutMultiplier" parameter of the + * ActorContext in "behat.yml". + * + * Every actor used in the scenarios must have a corresponding Mink session + * declared in "behat.yml" with the same name as the actor. All used sessions + * are stopped after each scenario is run. + */ +class ActorContext extends RawMinkContext { + + /** + * @var array + */ + private $actors; + + /** + * @var Actor + */ + private $currentActor; + + /** + * @var float + */ + private $actorFindTimeoutMultiplier; + + /** + * Creates a new ActorContext. + * + * @param float $actorFindTimeoutMultiplier the find timeout multiplier to + * set in the Actors. + */ + public function __construct($actorFindTimeoutMultiplier = 1) { + $this->actorFindTimeoutMultiplier = $actorFindTimeoutMultiplier; + } + + /** + * Sets a Mink parameter. + * + * When the "base_url" parameter is set its value is propagated to all the + * Actors. + * + * @param string $name the name of the parameter. + * @param string $value the value of the parameter. + */ + public function setMinkParameter($name, $value) { + parent::setMinkParameter($name, $value); + + if ($name === "base_url") { + foreach ($this->actors as $actor) { + $actor->setBaseUrl($value); + } + } + } + + /** + * @BeforeScenario + * + * Initializes the Actors for the new Scenario with the default Actor. + * + * Other Actors are added (and their Mink Sessions started) only when they + * are used in an "I act as XXX" step. + */ + public function initializeActors() { + $this->actors = array(); + + $this->actors["default"] = new Actor($this->getSession(), $this->getMinkParameter("base_url")); + $this->actors["default"]->setFindTimeoutMultiplier($this->actorFindTimeoutMultiplier); + + $this->currentActor = $this->actors["default"]; + } + + /** + * @BeforeStep + */ + public function setCurrentActorInSiblingActorAwareContexts(BeforeStepScope $scope) { + $environment = $scope->getEnvironment(); + + foreach ($environment->getContexts() as $context) { + if ($context instanceof ActorAwareInterface) { + $context->setCurrentActor($this->currentActor); + } + } + } + + /** + * @Given I act as :actorName + */ + public function iActAs($actorName) { + if (!array_key_exists($actorName, $this->actors)) { + $this->actors[$actorName] = new Actor($this->getSession($actorName), $this->getMinkParameter("base_url")); + $this->actors[$actorName]->setFindTimeoutMultiplier($this->actorFindTimeoutMultiplier); + } + + $this->currentActor = $this->actors[$actorName]; + } + + /** + * @AfterScenario + * + * Stops all the Mink Sessions used in the last Scenario. + */ + public function cleanUpSessions() { + foreach ($this->actors as $actor) { + $actor->getSession()->stop(); + } + } + +} diff --git a/tests/acceptance/features/core/Locator.php b/tests/acceptance/features/core/Locator.php new file mode 100644 index 00000000000..0ebae9b8fb1 --- /dev/null +++ b/tests/acceptance/features/core/Locator.php @@ -0,0 +1,329 @@ +<?php + +/** + * + * @copyright Copyright (c) 2017, Daniel Calviño Sánchez (danxuliu@gmail.com) + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ + +/** + * Data object for the information needed to locate an element in a web page + * using Mink. + * + * Locators can be created directly using the constructor, or through a more + * fluent interface with Locator::forThe(). + */ +class Locator { + + /** + * @var string + */ + private $description; + + /** + * @var string + */ + private $selector; + + /** + * @var string|array + */ + private $locator; + + /** + * @var null|Locator|\Behat\Mink\Element\ElementInterface + */ + private $ancestor; + + /** + * Starting point for the fluent interface to create Locators. + * + * @return LocatorBuilder + */ + public static function forThe() { + return new LocatorBuilder(); + } + + /** + * @param string $description + * @param string $selector + * @param string|array $locator + * @param null|Locator|\Behat\Mink\Element\ElementInterface $ancestor + */ + public function __construct($description, $selector, $locator, $ancestor = null) { + $this->description = $description; + $this->selector = $selector; + $this->locator = $locator; + $this->ancestor = $ancestor; + } + + /** + * @return string + */ + public function getDescription() { + return $this->description; + } + + /** + * @return string + */ + public function getSelector() { + return $this->selector; + } + + /** + * @return string|array + */ + public function getLocator() { + return $this->locator; + } + + /** + * @return null|Locator|\Behat\Mink\Element\ElementInterface + */ + public function getAncestor() { + return $this->ancestor; + } + +} + +class LocatorBuilder { + + /** + * @param string $selector + * @param string|array $locator + * @return LocatorBuilderSecondStep + */ + public function customSelector($selector, $locator) { + return new LocatorBuilderSecondStep($selector, $locator); + } + + /** + * @param string $cssExpression + * @return LocatorBuilderSecondStep + */ + public function css($cssExpression) { + return $this->customSelector("css", $cssExpression); + } + + /** + * @param string $xpathExpression + * @return LocatorBuilderSecondStep + */ + public function xpath($xpathExpression) { + return $this->customSelector("xpath", $xpathExpression); + } + + /** + * @param string $value + * @return LocatorBuilderSecondStep + */ + public function id($value) { + return $this->customSelector("named", array("id", $value)); + } + + /** + * @param string $value + * @return LocatorBuilderSecondStep + */ + public function idOrName($value) { + return $this->customSelector("named", array("id_or_name", $value)); + } + + /** + * @param string $value + * @return LocatorBuilderSecondStep + */ + public function link($value) { + return $this->customSelector("named", array("link", $value)); + } + + /** + * @param string $value + * @return LocatorBuilderSecondStep + */ + public function button($value) { + return $this->customSelector("named", array("button", $value)); + } + + /** + * @param string $value + * @return LocatorBuilderSecondStep + */ + public function linkOrButton($value) { + return $this->customSelector("named", array("link_or_button", $value)); + } + + /** + * @param string $value + * @return LocatorBuilderSecondStep + */ + public function content($value) { + return $this->customSelector("named", array("content", $value)); + } + + /** + * @param string $value + * @return LocatorBuilderSecondStep + */ + public function field($value) { + return $this->customSelector("named", array("field", $value)); + } + + /** + * @param string $value + * @return LocatorBuilderSecondStep + */ + public function selectField($value) { + return $this->customSelector("named", array("select", $value)); + } + + /** + * @param string $value + * @return LocatorBuilderSecondStep + */ + public function checkbox($value) { + return $this->customSelector("named", array("checkbox", $value)); + } + + /** + * @param string $value + * @return LocatorBuilderSecondStep + */ + public function radioButton($value) { + return $this->customSelector("named", array("radio", $value)); + } + + /** + * @param string $value + * @return LocatorBuilderSecondStep + */ + public function fileInput($value) { + return $this->customSelector("named", array("file", $value)); + } + + /** + * @param string $value + * @return LocatorBuilderSecondStep + */ + public function optionGroup($value) { + return $this->customSelector("named", array("optgroup", $value)); + } + + /** + * @param string $value + * @return LocatorBuilderSecondStep + */ + public function option($value) { + return $this->customSelector("named", array("option", $value)); + } + + /** + * @param string $value + * @return LocatorBuilderSecondStep + */ + public function fieldSet($value) { + return $this->customSelector("named", array("fieldset", $value)); + } + + /** + * @param string $value + * @return LocatorBuilderSecondStep + */ + public function table($value) { + return $this->customSelector("named", array("table", $value)); + } + +} + +class LocatorBuilderSecondStep { + + /** + * @var string + */ + private $selector; + + /** + * @var string|array + */ + private $locator; + + /** + * @param string $selector + * @param string|array $locator + */ + public function __construct($selector, $locator) { + $this->selector = $selector; + $this->locator = $locator; + } + + /** + * @param Locator|\Behat\Mink\Element\ElementInterface $ancestor + * @return LocatorBuilderThirdStep + */ + public function descendantOf($ancestor) { + return new LocatorBuilderThirdStep($this->selector, $this->locator, $ancestor); + } + + /** + * @param string $description + * @return Locator + */ + public function describedAs($description) { + return new Locator($description, $this->selector, $this->locator); + } + +} + +class LocatorBuilderThirdStep { + + /** + * @var string + */ + private $selector; + + /** + * @var string|array + */ + private $locator; + + /** + * @var Locator|\Behat\Mink\Element\ElementInterface + */ + private $ancestor; + + /** + * @param string $selector + * @param string|array $locator + * @param Locator|\Behat\Mink\Element\ElementInterface $ancestor + */ + public function __construct($selector, $locator, $ancestor) { + $this->selector = $selector; + $this->locator = $locator; + $this->ancestor = $ancestor; + } + + /** + * @param string $description + * @return Locator + */ + public function describedAs($description) { + return new Locator($description, $this->selector, $this->locator, $this->ancestor); + } + +} diff --git a/tests/acceptance/features/core/NextcloudTestServerContext.php b/tests/acceptance/features/core/NextcloudTestServerContext.php new file mode 100644 index 00000000000..f8d13a656b9 --- /dev/null +++ b/tests/acceptance/features/core/NextcloudTestServerContext.php @@ -0,0 +1,128 @@ +<?php + +/** + * + * @copyright Copyright (c) 2017, Daniel Calviño Sánchez (danxuliu@gmail.com) + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ + +use Behat\Behat\Context\Context; +use Behat\Behat\Hook\Scope\BeforeScenarioScope; + +/** + * Behat context to run each scenario against a clean Nextcloud server. + * + * Before each scenario is run, this context sets up a fresh Nextcloud server + * with predefined data and configuration. Thanks to this every scenario is + * independent from the others and they all know the initial state of the + * server. + * + * This context is expected to be used along with RawMinkContext contexts (or + * subclasses). As the server address can be different for each scenario, this + * context automatically sets the "base_url" parameter of all its sibling + * RawMinkContexts; just add NextcloudTestServerContext to the context list of a + * suite in "behat.yml". + * + * The Nextcloud server is provided by an instance of NextcloudTestServerHelper; + * its class must be specified when this context is created. By default, + * "NextcloudTestServerLocalHelper" is used, although that can be customized + * using the "nextcloudTestServerHelper" parameter in "behat.yml". In the same + * way, the parameters to be passed to the helper when it is created can be + * customized using the "nextcloudTestServerHelperParameters" parameter, which + * is an array (without keys) with the value of the parameters in the same order + * as in the constructor of the helper class (by default, [ ]). + * + * Example of custom parameters in "behat.yml": + * default: + * suites: + * default: + * contexts: + * - NextcloudTestServerContext: + * nextcloudTestServerHelper: NextcloudTestServerCustomHelper + * nextcloudTestServerHelperParameters: + * - first-parameter-value + * - second-parameter-value + */ +class NextcloudTestServerContext implements Context { + + /** + * @var NextcloudTestServerHelper + */ + private $nextcloudTestServerHelper; + + /** + * Creates a new NextcloudTestServerContext. + * + * @param string $nextcloudTestServerHelper the name of the + * NextcloudTestServerHelper implementing class to use. + * @param array $nextcloudTestServerHelperParameters the parameters for the + * constructor of the $nextcloudTestServerHelper class. + */ + public function __construct($nextcloudTestServerHelper = "NextcloudTestServerLocalHelper", + $nextcloudTestServerHelperParameters = [ ]) { + $nextcloudTestServerHelperClass = new ReflectionClass($nextcloudTestServerHelper); + + if ($nextcloudTestServerHelperParameters === null) { + $nextcloudTestServerHelperParameters = array(); + } + + $this->nextcloudTestServerHelper = $nextcloudTestServerHelperClass->newInstanceArgs($nextcloudTestServerHelperParameters); + } + + /** + * @BeforeScenario + * + * Sets up the Nextcloud test server before each scenario. + * + * Once the Nextcloud test server is set up, the "base_url" parameter of the + * sibling RawMinkContexts is set to the base URL of the Nextcloud test + * server. + * + * @param \Behat\Behat\Hook\Scope\BeforeScenarioScope $scope the + * BeforeScenario hook scope. + * @throws \Exception if the Nextcloud test server can not be set up or its + * base URL got. + */ + public function setUpNextcloudTestServer(BeforeScenarioScope $scope) { + $this->nextcloudTestServerHelper->setUp(); + + $this->setBaseUrlInSiblingRawMinkContexts($scope, $this->nextcloudTestServerHelper->getBaseUrl()); + } + + /** + * @AfterScenario + * + * Cleans up the Nextcloud test server after each scenario. + * + * @throws \Exception if the Nextcloud test server can not be cleaned up. + */ + public function cleanUpNextcloudTestServer() { + $this->nextcloudTestServerHelper->cleanUp(); + } + + private function setBaseUrlInSiblingRawMinkContexts(BeforeScenarioScope $scope, $baseUrl) { + $environment = $scope->getEnvironment(); + + foreach ($environment->getContexts() as $context) { + if ($context instanceof Behat\MinkExtension\Context\RawMinkContext) { + $context->setMinkParameter("base_url", $baseUrl); + } + } + } + +} diff --git a/tests/acceptance/features/core/NextcloudTestServerHelper.php b/tests/acceptance/features/core/NextcloudTestServerHelper.php new file mode 100644 index 00000000000..198d78e3fcb --- /dev/null +++ b/tests/acceptance/features/core/NextcloudTestServerHelper.php @@ -0,0 +1,73 @@ +<?php + +/** + * + * @copyright Copyright (c) 2017, Daniel Calviño Sánchez (danxuliu@gmail.com) + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ + +/** + * Interface for classes that manage a Nextcloud server during acceptance tests. + * + * A NextcloudTestServerHelper takes care of setting up a Nextcloud server to be + * used in acceptance tests through its "setUp" method. It does not matter + * wheter the server is a fresh new server just started or an already running + * server; in any case, the state of the server must comply with the initial + * state expected by the tests (like having performed the Nextcloud installation + * or having an admin user with certain password). + * + * As the IP address and thus its the base URL of the server is not known + * beforehand, the NextcloudTestServerHelper must provide it through its + * "getBaseUrl" method. Note that this must be the base URL from the point of + * view of the Selenium server, which may be a different value than the base URL + * from the point of view of the acceptance tests themselves. + * + * Once the Nextcloud test server is no longer needed the "cleanUp" method will + * be called; depending on how the Nextcloud test server was set up it may not + * need to do anything. + * + * All the methods throw an exception if they fail to execute; as, due to the + * current use of this interface, it is just a warning for the test runner and + * nothing to be explicitly catched a plain base Exception is used. + */ +interface NextcloudTestServerHelper { + + /** + * Sets up the Nextcloud test server. + * + * @throws \Exception if the Nextcloud test server can not be set up. + */ + public function setUp(); + + /** + * Cleans up the Nextcloud test server. + * + * @throws \Exception if the Nextcloud test server can not be cleaned up. + */ + public function cleanUp(); + + /** + * Returns the base URL of the Nextcloud test server (from the point of view + * of the Selenium server). + * + * @return string the base URL of the Nextcloud test server. + * @throws \Exception if the base URL can not be determined. + */ + public function getBaseUrl(); + +} diff --git a/tests/acceptance/features/core/NextcloudTestServerLocalHelper.php b/tests/acceptance/features/core/NextcloudTestServerLocalHelper.php new file mode 100644 index 00000000000..32b5330c61a --- /dev/null +++ b/tests/acceptance/features/core/NextcloudTestServerLocalHelper.php @@ -0,0 +1,134 @@ +<?php + +/** + * + * @copyright Copyright (c) 2017, Daniel Calviño Sánchez (danxuliu@gmail.com) + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ + +/** + * Helper to manage a Nextcloud test server started directly by the acceptance + * tests themselves using the PHP built-in web server. + * + * The Nextcloud test server is executed using the PHP built-in web server + * directly from the grandparent directory of the acceptance tests directory + * (that is, the root directory of the Nextcloud server); note that the + * acceptance tests must be run from the acceptance tests directory. The "setUp" + * method resets the Nextcloud server to its initial state and starts it, while + * the "cleanUp" method stops it. To be able to reset the Nextcloud server to + * its initial state a Git repository must be provided in the root directory of + * the Nextcloud server; the last commit in that Git repository must provide the + * initial state for the Nextcloud server expected by the acceptance tests. + * + * The Nextcloud server is available at "127.0.0.1", so it is expected to see + * "127.0.0.1" as a trusted domain (which would be the case if it was installed + * by running "occ maintenance:install"). The base URL to access the Nextcloud + * server can be got from "getBaseUrl". + */ +class NextcloudTestServerLocalHelper implements NextcloudTestServerHelper { + + /** + * @var string + */ + private $phpServerPid; + + /** + * Creates a new NextcloudTestServerLocalHelper. + */ + public function __construct() { + $this->phpServerPid = ""; + } + + /** + * Sets up the Nextcloud test server. + * + * It resets the Nextcloud test server restoring its last saved Git state + * and then waits for the Nextcloud test server to start again; if the + * server can not be reset or if it does not start again after some time an + * exception is thrown (as it is just a warning for the test runner and + * nothing to be explicitly catched a plain base Exception is used). + * + * @throws \Exception if the Nextcloud test server can not be reset or + * started again. + */ + public function setUp() { + // Ensure that previous PHP server is not running (as cleanUp may not + // have been called). + $this->killPhpServer(); + + $this->execOrException("cd ../../ && git reset --hard HEAD"); + $this->execOrException("cd ../../ && git clean -d --force"); + + // execOrException is not used because the server is started in the + // background, so the command will always succeed even if the server + // itself fails. + $this->phpServerPid = exec("php -S 127.0.0.1:80 -t ../../ >/dev/null 2>&1 & echo $!"); + + $timeout = 60; + if (!Utils::waitForServer($this->getBaseUrl(), $timeout)) { + throw new Exception("Nextcloud test server could not be started"); + } + } + + /** + * Cleans up the Nextcloud test server. + * + * It kills the running Nextcloud test server, if any. + */ + public function cleanUp() { + $this->killPhpServer(); + } + + /** + * Returns the base URL of the Nextcloud test server. + * + * @return string the base URL of the Nextcloud test server. + */ + public function getBaseUrl() { + return "http://127.0.0.1/index.php"; + } + + /** + * Executes the given command, throwing an Exception if it fails. + * + * @param string $command the command to execute. + * @throws \Exception if the command fails to execute. + */ + private function execOrException($command) { + exec($command . " 2>&1", $output, $returnValue); + if ($returnValue != 0) { + throw new Exception("'$command' could not be executed: " . implode("\n", $output)); + } + } + + /** + * Kills the PHP built-in web server started in setUp, if any. + */ + private function killPhpServer() { + if ($this->phpServerPid == "") { + return; + } + + // execOrException is not used because the PID may no longer exist when + // trying to kill it. + exec("kill " . $this->phpServerPid); + + $this->phpServerPid = ""; + } + +} diff --git a/tests/acceptance/features/core/NoSuchElementException.php b/tests/acceptance/features/core/NoSuchElementException.php new file mode 100644 index 00000000000..5f8270d2a49 --- /dev/null +++ b/tests/acceptance/features/core/NoSuchElementException.php @@ -0,0 +1,37 @@ +<?php + +/** + * + * @copyright Copyright (c) 2017, Daniel Calviño Sánchez (danxuliu@gmail.com) + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ + +/** + * Exception to signal that the element looked for could not be found. + */ +class NoSuchElementException extends \Exception { + + /** + * @param string $message + * @param null|\Exception $previous + */ + public function __construct($message, \Exception $previous = null) { + parent::__construct($message, 0, $previous); + } + +} diff --git a/tests/acceptance/features/core/Utils.php b/tests/acceptance/features/core/Utils.php new file mode 100644 index 00000000000..86b7515e4c6 --- /dev/null +++ b/tests/acceptance/features/core/Utils.php @@ -0,0 +1,90 @@ +<?php + +/** + * + * @copyright Copyright (c) 2017, Daniel Calviño Sánchez (danxuliu@gmail.com) + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ + +class Utils { + + /** + * Waits at most $timeout seconds for the given condition to be true, + * checking it again every $timeoutStep seconds. + * + * Note that the timeout is no longer taken into account when a condition is + * met; that is, true will be returned if the condition is met before the + * timeout expires, but also if it is met exactly when the timeout expires. + * For example, even if the timeout is set to 0, the condition will be + * checked at least once, and true will be returned in that case if the + * condition was met. + * + * @param \Closure $conditionCallback the condition to wait for, as a + * function that returns a boolean. + * @param float $timeout the number of seconds (decimals allowed) to wait at + * most for the condition to be true. + * @param float $timeoutStep the number of seconds (decimals allowed) to + * wait before checking the condition again. + * @return boolean true if the condition is met before (or exactly when) the + * timeout expires, false otherwise. + */ + public static function waitFor($conditionCallback, $timeout, $timeoutStep) { + $elapsedTime = 0; + $conditionMet = false; + + while (!($conditionMet = $conditionCallback()) && $elapsedTime < $timeout) { + usleep($timeoutStep * 1000000); + + $elapsedTime += $timeoutStep; + } + + return $conditionMet; + } + + /** + * Waits at most $timeout seconds for the server at the given URL to be up, + * checking it again every $timeoutStep seconds. + * + * Note that it does not verify whether the URL returns a valid HTTP status + * or not; it simply checks that the server at the given URL is accessible. + * + * @param string $url the URL for the server to check. + * @param float $timeout the number of seconds (decimals allowed) to wait at + * most for the server. + * @param float $timeoutStep the number of seconds (decimals allowed) to + * wait before checking the server again; by default, 0.5 seconds. + * @return boolean true if the server was found, false otherwise. + */ + public static function waitForServer($url, $timeout, $timeoutStep = 0.5) { + $isServerUpCallback = function() use ($url) { + $curlHandle = curl_init($url); + + // Returning the transfer as the result of curl_exec prevents the + // transfer from being written to the output. + curl_setopt($curlHandle, CURLOPT_RETURNTRANSFER, true); + + $transfer = curl_exec($curlHandle); + + curl_close($curlHandle); + + return $transfer !== false; + }; + return self::waitFor($isServerUpCallback, $timeout, $timeoutStep); + } + +} diff --git a/tests/acceptance/features/login.feature b/tests/acceptance/features/login.feature new file mode 100644 index 00000000000..e414209206e --- /dev/null +++ b/tests/acceptance/features/login.feature @@ -0,0 +1,47 @@ +Feature: login + + Scenario: log in with valid user and password + Given I visit the Home page + When I log in with user user0 and password 123456acb + Then I see that the current page is the Files app + + Scenario: try to log in with valid user and invalid password + Given I visit the Home page + When I log in with user user0 and password 654321 + Then I see that the current page is the Login page + And I see that a wrong password message is shown + + Scenario: log in with valid user and invalid password once fixed by admin + Given I act as John + And I can not log in with user user0 and password 654231 + When I act as Jane + And I am logged in as the admin + And I open the User settings + And I set the password for user0 to 654321 + And I see that the "Password successfully changed" notification is shown + And I act as John + And I log in with user user0 and password 654321 + Then I see that the current page is the Files app + + Scenario: try to log in with invalid user + Given I visit the Home page + When I log in with user unknownUser and password 123456acb + Then I see that the current page is the Login page + And I see that a wrong password message is shown + + Scenario: log in with invalid user once fixed by admin + Given I act as John + And I can not log in with user unknownUser and password 123456acb + When I act as Jane + And I am logged in as the admin + And I open the User settings + And I create user unknownUser with password 123456acb + And I see that the list of users contains the user unknownUser + And I act as John + And I log in with user unknownUser and password 123456acb + Then I see that the current page is the Files app + + Scenario: log out + Given I am logged in + When I log out + Then I see that the current page is the Login page diff --git a/tests/acceptance/installAndConfigureServer.sh b/tests/acceptance/installAndConfigureServer.sh new file mode 100755 index 00000000000..2fbdf821f77 --- /dev/null +++ b/tests/acceptance/installAndConfigureServer.sh @@ -0,0 +1,30 @@ +#!/bin/bash + +# @copyright Copyright (c) 2017, Daniel Calviño Sánchez (danxuliu@gmail.com) +# +# @license GNU AGPL version 3 or any later version +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +# Helper script to install and configure the Nextcloud server as expected by the +# acceptance tests. +# +# This script is not meant to be called manually; it is called when needed by +# the acceptance tests launchers. + +set -o errexit + +php occ maintenance:install --admin-pass=admin + +OC_PASS=123456acb php occ user:add --password-from-env user0 diff --git a/tests/acceptance/run-local.sh b/tests/acceptance/run-local.sh new file mode 100755 index 00000000000..ee7a4e6455c --- /dev/null +++ b/tests/acceptance/run-local.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash + +# @copyright Copyright (c) 2017, Daniel Calviño Sánchez (danxuliu@gmail.com) +# +# @license GNU AGPL version 3 or any later version +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +# Helper script to run the acceptance tests, which test a running Nextcloud +# instance from the point of view of a real user, configured to start the +# Nextcloud server themselves and from their grandparent directory. +# +# The acceptance tests are written in Behat so, besides running the tests, this +# script installs Behat, its dependencies, and some related packages in the +# "vendor" subdirectory of the acceptance tests. The acceptance tests expect +# that the last commit in the Git repository provides the default state of the +# Nextcloud server, so the script installs the Nextcloud server and saves a +# snapshot of the whole grandparent directory (no .gitignore file is used) in +# the Git repository. Finally, the acceptance tests also use the Selenium server +# to control a web browser, so this script waits for the Selenium server +# (which should have been started before executing this script) to be ready +# before running the tests. + +# Exit immediately on errors. +set -o errexit + +# Ensure working directory is script directory, as some actions (like installing +# Behat through Composer or running Behat) expect that. +cd "$(dirname $0)" + +# Safety parameter to prevent executing this script by mistake and messing with +# the Git repository. +if [ "$1" != "allow-git-repository-modifications" ]; then + echo "To run the acceptance tests use \"run.sh\" instead" + + exit 1 +fi + +SCENARIO_TO_RUN=$2 + +composer install + +cd ../../ + +echo "Installing and configuring Nextcloud server" +tests/acceptance/installAndConfigureServer.sh + +echo "Saving the default state so acceptance tests can reset to it" +find . -name ".gitignore" -exec rm --force {} \; +git add --all && echo 'Default state' | git -c user.name='John Doe' -c user.email='john@doe.org' commit --quiet --file=- + +cd tests/acceptance + +# Ensure that the Selenium server is ready before running the tests. +echo "Waiting for Selenium" +timeout 60s bash -c "while ! curl 127.0.0.1:4444 >/dev/null 2>&1; do sleep 1; done" + +vendor/bin/behat $SCENARIO_TO_RUN diff --git a/tests/acceptance/run.sh b/tests/acceptance/run.sh new file mode 100755 index 00000000000..f9711cbb404 --- /dev/null +++ b/tests/acceptance/run.sh @@ -0,0 +1,178 @@ +#!/usr/bin/env bash + +# @copyright Copyright (c) 2017, Daniel Calviño Sánchez (danxuliu@gmail.com) +# +# @license GNU AGPL version 3 or any later version +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +# Helper script to run the acceptance tests, which test a running Nextcloud +# instance from the point of view of a real user. +# +# The acceptance tests are run in its own Docker container; the grandparent +# directory of the acceptance tests directory (that is, the root directory of +# the Nextcloud server) is copied to the container and the acceptance tests are +# run inside it. Once the tests end the container is stopped. The acceptance +# tests also use the Selenium server to control a web browser, so the Selenium +# server is also launched before the tests start in its own Docker container (it +# will be stopped automatically too once the tests end). +# +# To perform its job, the script requires the "docker" command to be available. +# +# The Docker Command Line Interface (the "docker" command) requires special +# permissions to talk to the Docker daemon, and those permissions are typically +# available only to the root user. Please see the Docker documentation to find +# out how to give access to a regular user to the Docker daemon: +# https://docs.docker.com/engine/installation/linux/linux-postinstall/ +# +# Note, however, that being able to communicate with the Docker daemon is the +# same as being able to get root privileges for the system. Therefore, you must +# give access to the Docker daemon (and thus run this script as) ONLY to trusted +# and secure users: +# https://docs.docker.com/engine/security/security/#docker-daemon-attack-surface +# +# Finally, take into account that this script will automatically remove the +# Docker containers named "selenium-nextcloud-local-test-acceptance" and +# "nextcloud-local-test-acceptance", even if the script did not create them +# (probably you will not have containers nor images with those names, but just +# in case). + +# Launches the Selenium server in a Docker container. +# +# The acceptance tests use Firefox by default but, unfortunately, Firefox >= 48 +# does not provide yet the same level of support as earlier versions for certain +# features related to automated testing. Therefore, the Docker image used is not +# the latest one, but an older version known to work. +# +# The acceptance tests expect the Selenium server to be accessible at +# "127.0.0.1:4444"; as the Selenium server container and the container in which +# the acceptance tests are run share the same network nothing else needs to be +# done for the acceptance tests to access the Selenium server and for the +# Selenium server to access the Nextcloud server. However, in order to ensure +# from this script that the Selenium server was started the 4444 port of its +# container is mapped to the 4444 port of the host. +# +# Besides the Selenium server, the Docker image also provides a VNC server, so +# the 5900 port of the container is also mapped to the 5900 port of the host. +# +# The Docker container started here will be automatically stopped when the +# script exits (see cleanUp). If the Selenium server can not be started then the +# script will be exited immediately with an error state; the most common cause +# for the Selenium server to fail to start is that another server is already +# using the mapped ports in the host. +# +# As the web browser is run inside the Docker container it is not visible by +# default. However, it can be viewed using VNC (for example, +# "vncviewer 127.0.0.1:5900"); when asked for the password use "secret". +function prepareSelenium() { + SELENIUM_CONTAINER=selenium-nextcloud-local-test-acceptance + + echo "Starting Selenium server" + docker run --detach --name=$SELENIUM_CONTAINER --publish 4444:4444 --publish 5900:5900 selenium/standalone-firefox-debug:2.53.1-beryllium + + echo "Waiting for Selenium server to be ready" + if ! timeout 10s bash -c "while ! curl 127.0.0.1:4444 >/dev/null 2>&1; do sleep 1; done"; then + echo "Could not start Selenium server; running" \ + "\"docker run --rm --publish 4444:4444 --publish 5900:5900 selenium/standalone-firefox-debug:2.53.1-beryllium\"" \ + "could give you a hint of the problem" + + exit 1 + fi +} + +# Creates a Docker container to run both the acceptance tests and the Nextcloud +# server used by them. +# +# This function starts a Docker container with a copy the Nextcloud code from +# the grandparent directory, although ignoring any configuration or data that it +# may provide (for example, if that directory was used directly to deploy a +# Nextcloud instance in a web server). As the Nextcloud code is copied to the +# container instead of referenced the original code can be modified while the +# acceptance tests are running without interfering in them. +function prepareDocker() { + NEXTCLOUD_LOCAL_CONTAINER=nextcloud-local-test-acceptance + + echo "Starting the Nextcloud container" + # As the Nextcloud server container uses the network of the Selenium server + # container the Nextcloud server can be accessed at "127.0.0.1" from the + # Selenium server. + # The container exits immediately if no command is given, so a Bash session + # is created to prevent that. + docker run --detach --name=$NEXTCLOUD_LOCAL_CONTAINER --network=container:$SELENIUM_CONTAINER --interactive --tty nextcloudci/php7.0:php7.0-7 bash + + # Use the $TMPDIR or, if not set, fall back to /tmp. + NEXTCLOUD_LOCAL_TAR="$(mktemp --tmpdir="${TMPDIR:-/tmp}" --suffix=.tar nextcloud-local-XXXXXXXXXX)" + + # Setting the user and group of files in the tar would be superfluous, as + # "docker cp" does not take them into account (the extracted files are set + # to root). + echo "Copying local Git working directory of Nextcloud to the container" + tar --create --file="$NEXTCLOUD_LOCAL_TAR" --exclude=".git" --exclude="./build" --exclude="./config/config.php" --exclude="./data" --exclude="./data-autotest" --exclude="./tests" --directory=../../ . + tar --append --file="$NEXTCLOUD_LOCAL_TAR" --directory=../../ tests/acceptance/ + + docker exec $NEXTCLOUD_LOCAL_CONTAINER mkdir /nextcloud + docker cp - $NEXTCLOUD_LOCAL_CONTAINER:/nextcloud/ < "$NEXTCLOUD_LOCAL_TAR" + + # run-local.sh expects a Git repository to be available in the root of the + # Nextcloud server, but it was excluded when the Git working directory was + # copied to the container to avoid copying the large and unneeded history of + # the repository. + docker exec $NEXTCLOUD_LOCAL_CONTAINER bash -c "cd nextcloud && git init" +} + +# Removes/stops temporal elements created/started by this script. +function cleanUp() { + # Disable (yes, "+" disables) exiting immediately on errors to ensure that + # all the cleanup commands are executed (well, no errors should occur during + # the cleanup anyway, but just in case). + set +o errexit + + echo "Cleaning up" + + if [ -f "$NEXTCLOUD_LOCAL_TAR" ]; then + echo "Removing $NEXTCLOUD_LOCAL_TAR" + rm $NEXTCLOUD_LOCAL_TAR + fi + + # The name filter must be specified as "^/XXX$" to get an exact match; using + # just "XXX" would match every name that contained "XXX". + if [ -n "$(docker ps --all --quiet --filter name="^/$NEXTCLOUD_LOCAL_CONTAINER$")" ]; then + echo "Removing Docker container $NEXTCLOUD_LOCAL_CONTAINER" + docker rm --volumes --force $NEXTCLOUD_LOCAL_CONTAINER + fi + + if [ -n "$(docker ps --all --quiet --filter name="^/$SELENIUM_CONTAINER$")" ]; then + echo "Removing Docker container $SELENIUM_CONTAINER" + docker rm --volumes --force $SELENIUM_CONTAINER + fi +} + +# Exit immediately on errors. +set -o errexit + +# Execute cleanUp when the script exits, either normally or due to an error. +trap cleanUp EXIT + +# Ensure working directory is script directory, as some actions (like copying +# the Git working directory to the container) expect that. +cd "$(dirname $0)" + +# If no parameter is provided to this script all the acceptance tests are run. +SCENARIO_TO_RUN=$1 + +prepareSelenium +prepareDocker + +echo "Running tests" +docker exec $NEXTCLOUD_LOCAL_CONTAINER bash -c "cd nextcloud && tests/acceptance/run-local.sh allow-git-repository-modifications $SCENARIO_TO_RUN" |