3 namespace Drupal\Tests;
5 use Behat\Mink\Driver\GoutteDriver;
6 use Drupal\Component\Render\FormattableMarkup;
7 use Drupal\Component\Utility\Html;
8 use Drupal\Component\Utility\UrlHelper;
9 use Drupal\Core\Session\AccountInterface;
10 use Drupal\Core\Session\AnonymousUserSession;
11 use Drupal\Core\Test\RefreshVariablesTrait;
15 * Provides UI helper methods.
19 use BrowserHtmlDebugTrait;
20 use AssertHelperTrait;
21 use RefreshVariablesTrait;
24 * The current user logged in using the Mink controlled browser.
26 * @var \Drupal\user\UserInterface
28 protected $loggedInUser = FALSE;
31 * The number of meta refresh redirects to follow, or NULL if unlimited.
35 protected $maximumMetaRefreshCount = NULL;
38 * The number of meta refresh redirects followed during ::drupalGet().
42 protected $metaRefreshCount = 0;
45 * Fills and submits a form.
48 * Field data in an associative array. Changes the current input fields
49 * (where possible) to the values indicated.
51 * A checkbox can be set to TRUE to be checked and should be set to FALSE to
53 * @param string $submit
54 * Value of the submit button whose click is to be emulated. For example,
55 * 'Save'. The processing of the request depends on this value. For example,
56 * a form may have one button with the value 'Save' and another button with
57 * the value 'Delete', and execute different code depending on which one is
59 * @param string $form_html_id
60 * (optional) HTML ID of the form to be submitted. On some pages
61 * there are many identical forms, so just using the value of the submit
62 * button is not enough. For example: 'trigger-node-presave-assign-form'.
63 * Note that this is not the Drupal $form_id, but rather the HTML ID of the
64 * form, which is typically the same thing but with hyphens replacing the
67 protected function submitForm(array $edit, $submit, $form_html_id = NULL) {
68 $assert_session = $this->assertSession();
71 if (isset($form_html_id)) {
72 $form = $assert_session->elementExists('xpath', "//form[@id='$form_html_id']");
73 $submit_button = $assert_session->buttonExists($submit, $form);
74 $action = $form->getAttribute('action');
77 $submit_button = $assert_session->buttonExists($submit);
78 $form = $assert_session->elementExists('xpath', './ancestor::form', $submit_button);
79 $action = $form->getAttribute('action');
82 // Edit the form values.
83 foreach ($edit as $name => $value) {
84 $field = $assert_session->fieldExists($name, $form);
86 // Provide support for the values '1' and '0' for checkboxes instead of
88 // @todo Get rid of supporting 1/0 by converting all tests cases using
89 // this to boolean values.
90 $field_type = $field->getAttribute('type');
91 if ($field_type === 'checkbox') {
92 $value = (bool) $value;
95 $field->setValue($value);
99 $this->prepareRequest();
100 $submit_button->press();
102 // Ensure that any changes to variables in the other thread are picked up.
103 $this->refreshVariables();
105 // Check if there are any meta refresh redirects (like Batch API pages).
106 if ($this->checkForMetaRefresh()) {
107 // We are finished with all meta refresh redirects, so reset the counter.
108 $this->metaRefreshCount = 0;
111 // Log only for JavascriptTestBase tests because for Goutte we log with
112 // ::getResponseLogHandler.
113 if ($this->htmlOutputEnabled && !($this->getSession()->getDriver() instanceof GoutteDriver)) {
114 $out = $this->getSession()->getPage()->getContent();
115 $html_output = 'POST request to: ' . $action .
116 '<hr />Ending URL: ' . $this->getSession()->getCurrentUrl();
117 $html_output .= '<hr />' . $out;
118 $html_output .= $this->getHtmlOutputHeaders();
119 $this->htmlOutput($html_output);
125 * Executes a form submission.
127 * It will be done as usual submit form with Mink.
129 * @param \Drupal\Core\Url|string $path
130 * Location of the post form. Either a Drupal path or an absolute path or
131 * NULL to post to the current page. For multi-stage forms you can set the
132 * path to NULL and have it post to the last received page. Example:
135 * // First step in form.
136 * $edit = array(...);
137 * $this->drupalPostForm('some_url', $edit, 'Save');
139 * // Second step in form.
140 * $edit = array(...);
141 * $this->drupalPostForm(NULL, $edit, 'Save');
144 * Field data in an associative array. Changes the current input fields
145 * (where possible) to the values indicated.
147 * When working with form tests, the keys for an $edit element should match
148 * the 'name' parameter of the HTML of the form. For example, the 'body'
149 * field for a node has the following HTML:
151 * <textarea id="edit-body-und-0-value" class="text-full form-textarea
152 * resize-vertical" placeholder="" cols="60" rows="9"
153 * name="body[0][value]"></textarea>
155 * When testing this field using an $edit parameter, the code becomes:
157 * $edit["body[0][value]"] = 'My test value';
160 * A checkbox can be set to TRUE to be checked and should be set to FALSE to
161 * be unchecked. Multiple select fields can be tested using 'name[]' and
162 * setting each of the desired values in an array:
165 * $edit['name[]'] = array('value1', 'value2');
167 * @todo change $edit to disallow NULL as a value for Drupal 9.
168 * https://www.drupal.org/node/2802401
169 * @param string $submit
170 * Value of the submit button whose click is to be emulated. For example,
171 * 'Save'. The processing of the request depends on this value. For example,
172 * a form may have one button with the value 'Save' and another button with
173 * the value 'Delete', and execute different code depending on which one is
176 * This function can also be called to emulate an Ajax submission. In this
177 * case, this value needs to be an array with the following keys:
178 * - path: A path to submit the form values to for Ajax-specific processing.
179 * - triggering_element: If the value for the 'path' key is a generic Ajax
180 * processing path, this needs to be set to the name of the element. If
181 * the name doesn't identify the element uniquely, then this should
182 * instead be an array with a single key/value pair, corresponding to the
183 * element name and value. The \Drupal\Core\Form\FormAjaxResponseBuilder
184 * uses this to find the #ajax information for the element, including
185 * which specific callback to use for processing the request.
187 * This can also be set to NULL in order to emulate an Internet Explorer
188 * submission of a form with a single text field, and pressing ENTER in that
189 * textfield: under these conditions, no button information is added to the
191 * @param array $options
192 * Options to be forwarded to the url generator.
193 * @param string|null $form_html_id
194 * (optional) HTML ID of the form to be submitted. On some pages
195 * there are many identical forms, so just using the value of the submit
196 * button is not enough. For example: 'trigger-node-presave-assign-form'.
197 * Note that this is not the Drupal $form_id, but rather the HTML ID of the
198 * form, which is typically the same thing but with hyphens replacing the
202 * (deprecated) The response content after submit form. It is necessary for
203 * backwards compatibility and will be removed before Drupal 9.0. You should
204 * just use the webAssert object for your assertions.
206 protected function drupalPostForm($path, $edit, $submit, array $options = [], $form_html_id = NULL) {
207 if (is_object($submit)) {
208 // Cast MarkupInterface objects to string.
209 $submit = (string) $submit;
211 if ($edit === NULL) {
214 if (is_array($edit)) {
215 $edit = $this->castSafeStrings($edit);
219 $this->drupalGet($path, $options);
222 $this->submitForm($edit, $submit, $form_html_id);
224 return $this->getSession()->getPage()->getContent();
228 * Logs in a user using the Mink controlled browser.
230 * If a user is already logged in, then the current user is logged out before
231 * logging in the specified user.
233 * Please note that neither the current user nor the passed-in user object is
234 * populated with data of the logged in user. If you need full access to the
235 * user object after logging in, it must be updated manually. If you also need
236 * access to the plain-text password of the user (set by drupalCreateUser()),
237 * e.g. to log in the same user again, then it must be re-assigned manually.
241 * $account = $this->drupalCreateUser(array());
242 * $this->drupalLogin($account);
243 * // Load real user object.
244 * $pass_raw = $account->passRaw;
245 * $account = User::load($account->id());
246 * $account->passRaw = $pass_raw;
249 * @param \Drupal\Core\Session\AccountInterface $account
250 * User object representing the user to log in.
252 * @see drupalCreateUser()
254 protected function drupalLogin(AccountInterface $account) {
255 if ($this->loggedInUser) {
256 $this->drupalLogout();
259 $this->drupalGet('user/login');
261 'name' => $account->getUsername(),
262 'pass' => $account->passRaw,
265 // @see ::drupalUserIsLoggedIn()
266 $account->sessionId = $this->getSession()->getCookie(\Drupal::service('session_configuration')->getOptions(\Drupal::request())['name']);
267 $this->assertTrue($this->drupalUserIsLoggedIn($account), new FormattableMarkup('User %name successfully logged in.', ['%name' => $account->getAccountName()]));
269 $this->loggedInUser = $account;
270 $this->container->get('current_user')->setAccount($account);
274 * Logs a user out of the Mink controlled browser and confirms.
276 * Confirms logout by checking the login page.
278 protected function drupalLogout() {
279 // Make a request to the logout page, and redirect to the user page, the
280 // idea being if you were properly logged out you should be seeing a login
282 $assert_session = $this->assertSession();
283 $this->drupalGet('user/logout', ['query' => ['destination' => 'user']]);
284 $assert_session->fieldExists('name');
285 $assert_session->fieldExists('pass');
287 // @see BrowserTestBase::drupalUserIsLoggedIn()
288 unset($this->loggedInUser->sessionId);
289 $this->loggedInUser = FALSE;
290 \Drupal::currentUser()->setAccount(new AnonymousUserSession());
294 * Returns WebAssert object.
296 * @param string $name
297 * (optional) Name of the session. Defaults to the active session.
299 * @return \Drupal\Tests\WebAssert
300 * A new web-assert option for asserting the presence of elements with.
302 public function assertSession($name = NULL) {
303 $this->addToAssertionCount(1);
304 return new WebAssert($this->getSession($name), $this->baseUrl);
308 * Retrieves a Drupal path or an absolute path.
310 * @param string|\Drupal\Core\Url $path
311 * Drupal path or URL to load into Mink controlled browser.
312 * @param array $options
313 * (optional) Options to be forwarded to the url generator.
314 * @param string[] $headers
315 * An array containing additional HTTP request headers, the array keys are
316 * the header names and the array values the header values. This is useful
317 * to set for example the "Accept-Language" header for requesting the page
318 * in a different language. Note that not all headers are supported, for
319 * example the "Accept" header is always overridden by the browser. For
320 * testing REST APIs it is recommended to obtain a separate HTTP client
321 * using getHttpClient() and performing requests that way.
324 * The retrieved HTML string, also available as $this->getRawContent()
326 * @see \Drupal\Tests\BrowserTestBase::getHttpClient()
328 protected function drupalGet($path, array $options = [], array $headers = []) {
329 $options['absolute'] = TRUE;
330 $url = $this->buildUrl($path, $options);
332 $session = $this->getSession();
334 $this->prepareRequest();
335 foreach ($headers as $header_name => $header_value) {
336 $session->setRequestHeader($header_name, $header_value);
339 $session->visit($url);
340 $out = $session->getPage()->getContent();
342 // Ensure that any changes to variables in the other thread are picked up.
343 $this->refreshVariables();
345 // Replace original page output with new output from redirected page(s).
346 if ($new = $this->checkForMetaRefresh()) {
348 // We are finished with all meta refresh redirects, so reset the counter.
349 $this->metaRefreshCount = 0;
352 // Log only for JavascriptTestBase tests because for Goutte we log with
353 // ::getResponseLogHandler.
354 if ($this->htmlOutputEnabled && !($this->getSession()->getDriver() instanceof GoutteDriver)) {
355 $html_output = 'GET request to: ' . $url .
356 '<hr />Ending URL: ' . $this->getSession()->getCurrentUrl();
357 $html_output .= '<hr />' . $out;
358 $html_output .= $this->getHtmlOutputHeaders();
359 $this->htmlOutput($html_output);
366 * Builds an a absolute URL from a system path or a URL object.
368 * @param string|\Drupal\Core\Url $path
369 * A system path or a URL.
370 * @param array $options
371 * Options to be passed to Url::fromUri().
374 * An absolute URL string.
376 protected function buildUrl($path, array $options = []) {
377 if ($path instanceof Url) {
378 $url_options = $path->getOptions();
379 $options = $url_options + $options;
380 $path->setOptions($options);
381 return $path->setAbsolute()->toString();
383 // The URL generator service is not necessarily available yet; e.g., in
384 // interactive installer tests.
385 elseif (\Drupal::hasService('url_generator')) {
386 $force_internal = isset($options['external']) && $options['external'] == FALSE;
387 if (!$force_internal && UrlHelper::isExternal($path)) {
388 return Url::fromUri($path, $options)->toString();
391 $uri = $path === '<front>' ? 'base:/' : 'base:/' . $path;
392 // Path processing is needed for language prefixing. Skip it when a
393 // path that may look like an external URL is being used as internal.
394 $options['path_processing'] = !$force_internal;
395 return Url::fromUri($uri, $options)
401 return $this->getAbsoluteUrl($path);
406 * Takes a path and returns an absolute path.
408 * @param string $path
409 * A path from the Mink controlled browser content.
412 * The $path with $base_url prepended, if necessary.
414 protected function getAbsoluteUrl($path) {
415 global $base_url, $base_path;
417 $parts = parse_url($path);
418 if (empty($parts['host'])) {
419 // Ensure that we have a string (and no xpath object).
420 $path = (string) $path;
421 // Strip $base_path, if existent.
422 $length = strlen($base_path);
423 if (substr($path, 0, $length) === $base_path) {
424 $path = substr($path, $length);
426 // Ensure that we have an absolute path.
427 if (empty($path) || $path[0] !== '/') {
430 // Finally, prepend the $base_url.
431 $path = $base_url . $path;
437 * Prepare for a request to testing site.
439 * The testing site is protected via a SIMPLETEST_USER_AGENT cookie that is
440 * checked by drupal_valid_test_ua().
442 * @see drupal_valid_test_ua()
444 protected function prepareRequest() {
445 $session = $this->getSession();
446 $session->setCookie('SIMPLETEST_USER_AGENT', drupal_generate_test_ua($this->databasePrefix));
450 * Returns whether a given user account is logged in.
452 * @param \Drupal\Core\Session\AccountInterface $account
453 * The user account object to check.
456 * Return TRUE if the user is logged in, FALSE otherwise.
458 protected function drupalUserIsLoggedIn(AccountInterface $account) {
461 if (isset($account->sessionId)) {
462 $session_handler = \Drupal::service('session_handler.storage');
463 $logged_in = (bool) $session_handler->read($account->sessionId);
470 * Clicks the element with the given CSS selector.
472 * @param string $css_selector
473 * The CSS selector identifying the element to click.
475 protected function click($css_selector) {
476 $starting_url = $this->getSession()->getCurrentUrl();
477 $this->getSession()->getDriver()->click($this->cssSelectToXpath($css_selector));
478 // Log only for JavascriptTestBase tests because for Goutte we log with
479 // ::getResponseLogHandler.
480 if ($this->htmlOutputEnabled && !($this->getSession()->getDriver() instanceof GoutteDriver)) {
481 $out = $this->getSession()->getPage()->getContent();
483 'Clicked element with CSS selector: ' . $css_selector .
484 '<hr />Starting URL: ' . $starting_url .
485 '<hr />Ending URL: ' . $this->getSession()->getCurrentUrl();
486 $html_output .= '<hr />' . $out;
487 $html_output .= $this->getHtmlOutputHeaders();
488 $this->htmlOutput($html_output);
493 * Follows a link by complete name.
495 * Will click the first link found with this link text.
497 * If the link is discovered and clicked, the test passes. Fail otherwise.
499 * @param string|\Drupal\Component\Render\MarkupInterface $label
500 * Text between the anchor tags.
502 * (optional) The index number for cases where multiple links have the same
503 * text. Defaults to 0.
505 protected function clickLink($label, $index = 0) {
506 $label = (string) $label;
507 $links = $this->getSession()->getPage()->findAll('named', ['link', $label]);
508 $this->assertArrayHasKey($index, $links, 'The link ' . $label . ' was not found on the page.');
509 $links[$index]->click();
513 * Retrieves the plain-text content from the current page.
515 protected function getTextContent() {
516 return $this->getSession()->getPage()->getText();
520 * Get the current URL from the browser.
525 protected function getUrl() {
526 return $this->getSession()->getCurrentUrl();
530 * Checks for meta refresh tag and if found call drupalGet() recursively.
532 * This function looks for the http-equiv attribute to be set to "Refresh" and
533 * is case-insensitive.
535 * @return string|false
536 * Either the new page content or FALSE.
538 protected function checkForMetaRefresh() {
539 $refresh = $this->cssSelect('meta[http-equiv="Refresh"], meta[http-equiv="refresh"]');
540 if (!empty($refresh) && (!isset($this->maximumMetaRefreshCount) || $this->metaRefreshCount < $this->maximumMetaRefreshCount)) {
541 // Parse the content attribute of the meta tag for the format:
542 // "[delay]: URL=[page_to_redirect_to]".
543 if (preg_match('/\d+;\s*URL=(?<url>.*)/i', $refresh[0]->getAttribute('content'), $match)) {
544 $this->metaRefreshCount++;
545 return $this->drupalGet($this->getAbsoluteUrl(Html::decodeEntities($match['url'])));
552 * Searches elements using a CSS selector in the raw content.
554 * The search is relative to the root element (HTML tag normally) of the page.
556 * @param string $selector
557 * CSS selector to use in the search.
559 * @return \Behat\Mink\Element\NodeElement[]
560 * The list of elements on the page that match the selector.
562 protected function cssSelect($selector) {
563 return $this->getSession()->getPage()->findAll('css', $selector);