diff --git a/core/profiles/standard/src/Tests/StandardTest.php b/core/profiles/standard/tests/src/Functional/StandardTest.php similarity index 98% rename from core/profiles/standard/src/Tests/StandardTest.php rename to core/profiles/standard/tests/src/Functional/StandardTest.php index bff61a9..4f9e756 100644 --- a/core/profiles/standard/src/Tests/StandardTest.php +++ b/core/profiles/standard/tests/src/Functional/StandardTest.php @@ -1,13 +1,13 @@ assertSession()->statusCodeEquals(200); + * @endcode + */ +trait AssertLegacyTrait { + + use BaseAssertLegacyTrait; + + /** + * Asserts that the element with the given CSS selector is present. + * + * @param string $css_selector + * The CSS selector identifying the element to check. + * + * @deprecated Scheduled for removal in Drupal 9.0.0. + * Use $this->assertSession()->elementExists() instead. + */ + protected function assertElementPresent($css_selector) { + $this->assertSession()->elementExists('css', $css_selector); + } + + /** + * Asserts that the element with the given CSS selector is not present. + * + * @param string $css_selector + * The CSS selector identifying the element to check. + * + * @deprecated Scheduled for removal in Drupal 9.0.0. + * Use $this->assertSession()->elementNotExists() instead. + */ + protected function assertElementNotPresent($css_selector) { + $this->assertSession()->elementNotExists('css', $css_selector); + } + + /** + * Passes if the page (with HTML stripped) contains the text. + * + * Note that stripping HTML tags also removes their attributes, such as + * the values of text fields. + * + * @param string $text + * Plain text to look for. + * + * @deprecated Scheduled for removal in Drupal 9.0.0. + * Use $this->assertSession()->responseContains() instead. + */ + protected function assertText($text) { + $this->assertSession()->responseContains($text); + } + + /** + * Passes if the page (with HTML stripped) does not contains the text. + * + * Note that stripping HTML tags also removes their attributes, such as + * the values of text fields. + * + * @param string $text + * Plain text to look for. + * + * @deprecated Scheduled for removal in Drupal 9.0.0. + * Use $this->assertSession()->responseNotContains() instead. + */ + protected function assertNoText($text) { + $this->assertSession()->responseNotContains($text); + } + + /** + * Asserts the page responds with the specified response code. + * + * @param int $code + * Response code. For example 200 is a successful page request. For a list + * of all codes see http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html. + * + * @deprecated Scheduled for removal in Drupal 9.0.0. + * Use $this->assertSession()->statusCodeEquals() instead. + */ + protected function assertResponse($code) { + $this->assertSession()->statusCodeEquals($code); + } + + /** + * Asserts that a field exists with the given name and value. + * + * @param string $name + * Name of field to assert. + * @param string $value + * (optional) Value of the field to assert. You may pass in NULL (default) + * to skip checking the actual value, while still checking that the field + * exists. + * + * @deprecated Scheduled for removal in Drupal 9.0.0. + * Use $this->assertSession()->fieldExists() or + * $this->assertSession()->fieldValueEquals() instead. + */ + protected function assertFieldByName($name, $value = NULL) { + $this->assertSession()->fieldExists($name); + if ($value !== NULL) { + $this->assertSession()->fieldValueEquals($name, $value); + } + } + + /** + * Passes if the raw text IS found on the loaded page, fail otherwise. + * + * Raw text refers to the raw HTML that the page generated. + * + * @param string $raw + * Raw (HTML) string to look for. + * + * @deprecated Scheduled for removal in Drupal 9.0.0. + * Use $this->assertSession()->responseContains() instead. + */ + protected function assertRaw($raw) { + $this->assertSession()->responseContains($raw); + } + + /** + * Pass if the page title is the given string. + * + * @param string $expected_title + * The string the page title should be. + */ + protected function assertTitle($expected_title) { + return $this->assertSession()->titleEquals($expected_title); + } + + /** + * Passes if a link with the specified label is found. + * + * An optional link index may be passed. + * + * @param string|\Drupal\Component\Render\MarkupInterface $label + * Text between the anchor tags. + * @param int $index + * Link position counting from zero. + */ + public function assertLink($label, $index = 0) { + return $this->assertSession()->linkExists($label, $index); + } + +} diff --git a/core/tests/Drupal/KernelTests/KernelTestBase.php b/core/tests/Drupal/KernelTests/KernelTestBase.php index d849d9a..8c88165 100644 --- a/core/tests/Drupal/KernelTests/KernelTestBase.php +++ b/core/tests/Drupal/KernelTests/KernelTestBase.php @@ -1221,6 +1221,9 @@ public function __sleep() { * {@inheritdoc} */ public static function assertEquals($expected, $actual, $message = '', $delta = 0.0, $maxDepth = 10, $canonicalize = FALSE, $ignoreCase = FALSE) { + // Cast objects implementing MarkupInterface to string instead of + // relying on PHP casting them to string depending on what they are being + // comparing with. $expected = static::castSafeStrings($expected); $actual = static::castSafeStrings($actual); parent::assertEquals($expected, $actual, $message, $delta, $maxDepth, $canonicalize, $ignoreCase); diff --git a/core/tests/Drupal/Tests/BrowserTestBase.php b/core/tests/Drupal/Tests/BrowserTestBase.php index a8700b4..5a9b0c0 100644 --- a/core/tests/Drupal/Tests/BrowserTestBase.php +++ b/core/tests/Drupal/Tests/BrowserTestBase.php @@ -20,9 +20,12 @@ use Drupal\Core\Test\TestRunnerKernel; use Drupal\Core\Url; use Drupal\Core\Test\TestDatabase; +use Drupal\FunctionalTests\AssertLegacyTrait; +use Drupal\simpletest\AssertHelperTrait; +use Drupal\simpletest\BlockCreationTrait; +use Drupal\simpletest\NodeCreationTrait; use Drupal\user\Entity\Role; use Drupal\user\Entity\User; -use Drupal\user\UserInterface; use Symfony\Component\CssSelector\CssSelectorConverter; use Symfony\Component\HttpFoundation\Request; @@ -36,8 +39,14 @@ * @ingroup testing */ abstract class BrowserTestBase extends \PHPUnit_Framework_TestCase { + use AssertHelperTrait; + use BlockCreationTrait; + use AssertLegacyTrait; use RandomGeneratorTrait; use SessionTestTrait; + use NodeCreationTrait { + createNode as drupalCreateNode; + } /** * Class loader. @@ -274,6 +283,13 @@ protected $baseUrl; /** + * The original array of shutdown function callbacks. + * + * @var array + */ + protected $originalShutdownCallbacks = []; + + /** * Initializes Mink sessions. */ protected function initMink() { @@ -505,6 +521,10 @@ protected function tearDown() { if ($this->mink) { $this->mink->stopSessions(); } + + // Restore original shutdown callbacks. + $callbacks = &drupal_register_shutdown_function(); + $callbacks = $this->originalShutdownCallbacks; } /** @@ -894,6 +914,90 @@ protected function submitForm(array $edit, $submit, $form_html_id = NULL) { } /** + * Executes a form submission. + * + * It will be done as usual POST request with Mink. + * + * @param \Drupal\Core\Url|string $path + * Location of the post form. Either a Drupal path or an absolute path or + * NULL to post to the current page. For multi-stage forms you can set the + * path to NULL and have it post to the last received page. Example: + * + * @code + * // First step in form. + * $edit = array(...); + * $this->drupalPostForm('some_url', $edit, t('Save')); + * + * // Second step in form. + * $edit = array(...); + * $this->drupalPostForm(NULL, $edit, t('Save')); + * @endcode + * @param array $edit + * Field data in an associative array. Changes the current input fields + * (where possible) to the values indicated. + * + * When working with form tests, the keys for an $edit element should match + * the 'name' parameter of the HTML of the form. For example, the 'body' + * field for a node has the following HTML: + * @code + * + * @endcode + * When testing this field using an $edit parameter, the code becomes: + * @code + * $edit["body[0][value]"] = 'My test value'; + * @endcode + * + * A checkbox can be set to TRUE to be checked and should be set to FALSE to + * be unchecked. Multiple select fields can be tested using 'name[]' and + * setting each of the desired values in an array: + * @code + * $edit = array(); + * $edit['name[]'] = array('value1', 'value2'); + * @endcode + * @param string $submit + * Value of the submit button whose click is to be emulated. For example, + * t('Save'). The processing of the request depends on this value. For + * example, a form may have one button with the value t('Save') and another + * button with the value t('Delete'), and execute different code depending + * on which one is clicked. + * + * This function can also be called to emulate an Ajax submission. In this + * case, this value needs to be an array with the following keys: + * - path: A path to submit the form values to for Ajax-specific processing. + * - triggering_element: If the value for the 'path' key is a generic Ajax + * processing path, this needs to be set to the name of the element. If + * the name doesn't identify the element uniquely, then this should + * instead be an array with a single key/value pair, corresponding to the + * element name and value. The \Drupal\Core\Form\FormAjaxResponseBuilder + * uses this to find the #ajax information for the element, including + * which specific callback to use for processing the request. + * + * This can also be set to NULL in order to emulate an Internet Explorer + * submission of a form with a single text field, and pressing ENTER in that + * textfield: under these conditions, no button information is added to the + * POST data. + * @param array $options + * Options to be forwarded to the url generator. + */ + protected function drupalPostForm($path, array $edit, $submit, array $options = array()) { + if (is_object($submit)) { + // Cast MarkupInterface objects to string. + $submit = (string) $submit; + } + if (is_array($edit)) { + $edit = $this->castSafeStrings($edit); + } + + if (isset($path)) { + $this->drupalGet($path, $options); + } + + $this->submitForm($edit, $submit); + } + + /** * Helper function to get the options of select field. * * @param \Behat\Mink\Element\NodeElement|string $select @@ -951,6 +1055,10 @@ public function installDrupal() { 'value' => $this->publicFilesDirectory, 'required' => TRUE, ); + $settings['settings']['file_private_path'] = (object) [ + 'value' => $this->privateFilesDirectory, + 'required' => TRUE, + ]; $this->writeSettings($settings); // Allow for test-specific overrides. $settings_testing_file = DRUPAL_ROOT . '/' . $this->originalSiteDirectory . '/settings.testing.php'; @@ -1000,13 +1108,13 @@ public function installDrupal() { $config = $container->get('config.factory'); // Manually create and configure private and temporary files directories. - // While these could be preset/enforced in settings.php like the public - // files directory above, some tests expect them to be configurable in the - // UI. If declared in settings.php, they would no longer be configurable. file_prepare_directory($this->privateFilesDirectory, FILE_CREATE_DIRECTORY); file_prepare_directory($this->tempFilesDirectory, FILE_CREATE_DIRECTORY); + // While the temporary files path could be preset/enforced in settings.php + // like the public files directory above, some tests expect it to be + // configurable in the UI. If declared in settings.php, it would no longer + // be configurable. $config->getEditable('system.file') - ->set('path.private', $this->privateFilesDirectory) ->set('path.temporary', $this->tempFilesDirectory) ->save(); @@ -1241,6 +1349,14 @@ protected function prepareEnvironment() { )); drupal_set_time_limit($this->timeLimit); + + // Save and clean the shutdown callbacks array because it is static cached + // and will be changed by the test run. Otherwise it will contain callbacks + // from both environments and the testing environment will try to call the + // handlers defined by the original one. + $callbacks = &drupal_register_shutdown_function(); + $this->originalShutdownCallbacks = $callbacks; + $callbacks = []; } /** @@ -1402,13 +1518,13 @@ protected function refreshVariables() { /** * Returns whether a given user account is logged in. * - * @param \Drupal\user\UserInterface $account + * @param \Drupal\Core\Session\AccountInterface $account * The user account object to check. * * @return bool * Return TRUE if the user is logged in, FALSE otherwise. */ - protected function drupalUserIsLoggedIn(UserInterface $account) { + protected function drupalUserIsLoggedIn(AccountInterface $account) { $logged_in = FALSE; if (isset($account->sessionId)) { @@ -1420,30 +1536,6 @@ protected function drupalUserIsLoggedIn(UserInterface $account) { } /** - * Asserts that the element with the given CSS selector is present. - * - * @param string $css_selector - * The CSS selector identifying the element to check. - * @param string $message - * Optional message to show alongside the assertion. - */ - protected function assertElementPresent($css_selector, $message = '') { - $this->assertNotEmpty($this->getSession()->getDriver()->find($this->cssSelectToXpath($css_selector)), $message); - } - - /** - * Asserts that the element with the given CSS selector is not present. - * - * @param string $css_selector - * The CSS selector identifying the element to check. - * @param string $message - * Optional message to show alongside the assertion. - */ - protected function assertElementNotPresent($css_selector, $message = '') { - $this->assertEmpty($this->getSession()->getDriver()->find($this->cssSelectToXpath($css_selector)), $message); - } - - /** * Clicks the element with the given CSS selector. * * @param string $css_selector @@ -1529,4 +1621,156 @@ protected function cssSelectToXpath($selector, $html = TRUE, $prefix = 'descenda return (new CssSelectorConverter($html))->toXPath($selector, $prefix); } + /** + * Searches elements using a CSS selector in the raw content. + * + * The search is relative to the root element (HTML tag normally) of the page. + * + * @param string $selector + * CSS selector to use in the search. + * + * @return \Behat\Mink\Element\NodeElement[] + * The list of elements on the page that match the selector. + */ + protected function cssSelect($selector) { + return $this->getSession()->getPage()->findAll('css', $selector); + } + + /** + * Follows a link by complete name. + * + * Will click the first link found with this link text. + * + * If the link is discovered and clicked, the test passes. Fail otherwise. + * + * @param string|\Drupal\Component\Render\MarkupInterface $label + * Text between the anchor tags. + */ + protected function clickLink($label) { + $label = (string) $label; + $this->getSession()->getPage()->clickLink($label); + } + + /** + * Retrieves the plain-text content from the current page. + */ + protected function getTextContent() { + return $this->getSession()->getPage()->getContent(); + } + + /** + * Performs an xpath search on the contents of the internal browser. + * + * The search is relative to the root element (HTML tag normally) of the page. + * + * @param string $xpath + * The xpath string to use in the search. + * @param array $arguments + * An array of arguments with keys in the form ':name' matching the + * placeholders in the query. The values may be either strings or numeric + * values. + * + * @return \Behat\Mink\Element\NodeElement[] + * The list of elements matching the xpath expression. + */ + protected function xpath($xpath, array $arguments = []) { + $xpath = $this->buildXPathQuery($xpath, $arguments); + return $this->getSession()->getPage()->findAll('xpath', $xpath); + } + + /** + * Builds an XPath query. + * + * Builds an XPath query by replacing placeholders in the query by the value + * of the arguments. + * + * XPath 1.0 (the version supported by libxml2, the underlying XML library + * used by PHP) doesn't support any form of quotation. This function + * simplifies the building of XPath expression. + * + * @param string $xpath + * An XPath query, possibly with placeholders in the form ':name'. + * @param array $args + * An array of arguments with keys in the form ':name' matching the + * placeholders in the query. The values may be either strings or numeric + * values. + * + * @return string + * An XPath query with arguments replaced. + */ + protected function buildXPathQuery($xpath, array $args = array()) { + // Replace placeholders. + foreach ($args as $placeholder => $value) { + // Cast MarkupInterface objects to string. + if (is_object($value)) { + $value = (string) $value; + } + // XPath 1.0 doesn't support a way to escape single or double quotes in a + // string literal. We split double quotes out of the string, and encode + // them separately. + if (is_string($value)) { + // Explode the text at the quote characters. + $parts = explode('"', $value); + + // Quote the parts. + foreach ($parts as &$part) { + $part = '"' . $part . '"'; + } + + // Return the string. + $value = count($parts) > 1 ? 'concat(' . implode(', \'"\', ', $parts) . ')' : $parts[0]; + } + + // Use preg_replace_callback() instead of preg_replace() to prevent the + // regular expression engine from trying to substitute backreferences. + $replacement = function ($matches) use ($value) { + return $value; + }; + $xpath = preg_replace_callback('/' . preg_quote($placeholder) . '\b/', $replacement, $xpath); + } + return $xpath; + } + + /** + * Configuration accessor for tests. Returns non-overridden configuration. + * + * @param string $name + * Configuration name. + * + * @return \Drupal\Core\Config\Config + * The configuration object with original configuration data. + */ + protected function config($name) { + return $this->container->get('config.factory')->getEditable($name); + } + + /** + * Gets the value of an HTTP response header. + * + * If multiple requests were required to retrieve the page, only the headers + * from the last request will be checked by default. + * + * @param string $name + * The name of the header to retrieve. Names are case-insensitive (see RFC + * 2616 section 4.2). + * + * @return string|null + * The HTTP header value or NULL if not found. + */ + protected function drupalGetHeader($name) { + return $this->getSession()->getResponseHeader($name); + } + + /** + * {@inheritdoc} + */ + public static function assertEquals($expected, $actual, $message = '', $delta = 0.0, $maxDepth = 10, $canonicalize = FALSE, $ignoreCase = FALSE) { + // Cast objects implementing MarkupInterface to string instead of + // relying on PHP casting them to string depending on what they are being + // comparing with. + $expected = static::castSafeStrings($expected); + $actual = static::castSafeStrings($actual); + parent::assertEquals($expected, $actual, $message, $delta, $maxDepth, $canonicalize, $ignoreCase); + } + } diff --git a/core/tests/Drupal/Tests/WebAssert.php b/core/tests/Drupal/Tests/WebAssert.php index d863de2..84fc744 100644 --- a/core/tests/Drupal/Tests/WebAssert.php +++ b/core/tests/Drupal/Tests/WebAssert.php @@ -2,9 +2,11 @@ namespace Drupal\Tests; +use Behat\Mink\Exception\ExpectationException; use Behat\Mink\WebAssert as MinkWebAssert; use Behat\Mink\Element\TraversableElement; use Behat\Mink\Exception\ElementNotFoundException; +use Drupal\Component\Utility\Html; /** * Defines a class with methods for asserting presence of elements during tests. @@ -64,4 +66,83 @@ public function selectExists($select, TraversableElement $container = NULL) { return $node; } + /** + * Pass if the page title is the given string. + * + * @param string $expected_title + * The string the page title should be. + * + * @throws \Behat\Mink\Exception\ExpectationException + * Thrown when element doesn't exist, or the title is a different one. + */ + public function titleEquals($expected_title) { + $title_element = $this->session->getPage()->find('css', 'title'); + if (!$title_element) { + throw new ExpectationException('No title element found on the page', $this->session); + } + $actual_title = $title_element->getText(); + $this->assert($expected_title === $actual_title, 'Title found'); + } + + /** + * Passes if a link with the specified label is found. + * + * An optional link index may be passed. + * + * @param string|\Drupal\Component\Render\MarkupInterface $label + * Text between the anchor tags. + * @param int $index + * Link position counting from zero. + * @param string $message + * (optional) A message to display with the assertion. Do not translate + * messages: use strtr() to embed variables in the message text, not + * t(). If left blank, a default message will be displayed. + * + * @throws \Behat\Mink\Exception\ExpectationException + * Thrown when element doesn't exist, or the link label is a different one. + */ + public function linkExists($label, $index = 0, $message = '') { + // Cast MarkupInterface objects to string. + $label = (string) $label; + $message = ($message ? $message : strtr('Link with label %label found.', ['%label' => $label])); + $links = $this->session->getPage()->findAll('named', ['link', $label]); + if (empty($links[$index])) { + throw new ExpectationException($message); + } + $this->assert($links[$index] !== NULL, $message); + } + + /** + * Passes if the raw text IS NOT found escaped on the loaded page. + * + * Raw text refers to the raw HTML that the page generated. + * + * @param string $raw + * Raw (HTML) string to look for. + */ + public function assertNoEscaped($raw) { + $this->pageTextNotContains(Html::escape($raw)); + } + + /** + * Asserts a condition. + * + * The parent method is overridden because it is a private method. + * + * @param bool $condition + * The condition. + * @param string $message + * The success message. + * + * @throws \Behat\Mink\Exception\ExpectationException + * When the condition is not fulfilled. + */ + public function assert($condition, $message) { + if ($condition) { + return; + } + + throw new ExpectationException($message, $this->session->getDriver()); + } + }