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 * The id, name, label or value of the submit button which is to be clicked.
171 * For example, 'Save'. The first element matched by
172 * \Drupal\Tests\WebAssert::buttonExists() will be used. The processing of
173 * the request depends on this value. For example, a form may have one
174 * button with the value 'Save' and another button with the value 'Delete',
175 * and execute different code depending on which one is clicked.
176 * @param array $options
177 * Options to be forwarded to the url generator.
178 * @param string|null $form_html_id
179 * (optional) HTML ID of the form to be submitted. On some pages
180 * there are many identical forms, so just using the value of the submit
181 * button is not enough. For example: 'trigger-node-presave-assign-form'.
182 * Note that this is not the Drupal $form_id, but rather the HTML ID of the
183 * form, which is typically the same thing but with hyphens replacing the
187 * (deprecated) The response content after submit form. It is necessary for
188 * backwards compatibility and will be removed before Drupal 9.0. You should
189 * just use the webAssert object for your assertions.
191 * @see \Drupal\Tests\WebAssert::buttonExists()
193 protected function drupalPostForm($path, $edit, $submit, array $options = [], $form_html_id = NULL) {
194 if (is_object($submit)) {
195 // Cast MarkupInterface objects to string.
196 $submit = (string) $submit;
198 if ($edit === NULL) {
201 if (is_array($edit)) {
202 $edit = $this->castSafeStrings($edit);
206 $this->drupalGet($path, $options);
209 $this->submitForm($edit, $submit, $form_html_id);
211 return $this->getSession()->getPage()->getContent();
215 * Logs in a user using the Mink controlled browser.
217 * If a user is already logged in, then the current user is logged out before
218 * logging in the specified user.
220 * Please note that neither the current user nor the passed-in user object is
221 * populated with data of the logged in user. If you need full access to the
222 * user object after logging in, it must be updated manually. If you also need
223 * access to the plain-text password of the user (set by drupalCreateUser()),
224 * e.g. to log in the same user again, then it must be re-assigned manually.
228 * $account = $this->drupalCreateUser(array());
229 * $this->drupalLogin($account);
230 * // Load real user object.
231 * $pass_raw = $account->passRaw;
232 * $account = User::load($account->id());
233 * $account->passRaw = $pass_raw;
236 * @param \Drupal\Core\Session\AccountInterface $account
237 * User object representing the user to log in.
239 * @see drupalCreateUser()
241 protected function drupalLogin(AccountInterface $account) {
242 if ($this->loggedInUser) {
243 $this->drupalLogout();
246 $this->drupalGet('user/login');
248 'name' => $account->getUsername(),
249 'pass' => $account->passRaw,
252 // @see ::drupalUserIsLoggedIn()
253 $account->sessionId = $this->getSession()->getCookie(\Drupal::service('session_configuration')->getOptions(\Drupal::request())['name']);
254 $this->assertTrue($this->drupalUserIsLoggedIn($account), new FormattableMarkup('User %name successfully logged in.', ['%name' => $account->getAccountName()]));
256 $this->loggedInUser = $account;
257 $this->container->get('current_user')->setAccount($account);
261 * Logs a user out of the Mink controlled browser and confirms.
263 * Confirms logout by checking the login page.
265 protected function drupalLogout() {
266 // Make a request to the logout page, and redirect to the user page, the
267 // idea being if you were properly logged out you should be seeing a login
269 $assert_session = $this->assertSession();
270 $this->drupalGet('user/logout', ['query' => ['destination' => 'user']]);
271 $assert_session->fieldExists('name');
272 $assert_session->fieldExists('pass');
274 // @see BrowserTestBase::drupalUserIsLoggedIn()
275 unset($this->loggedInUser->sessionId);
276 $this->loggedInUser = FALSE;
277 \Drupal::currentUser()->setAccount(new AnonymousUserSession());
281 * Returns WebAssert object.
283 * @param string $name
284 * (optional) Name of the session. Defaults to the active session.
286 * @return \Drupal\Tests\WebAssert
287 * A new web-assert option for asserting the presence of elements with.
289 public function assertSession($name = NULL) {
290 $this->addToAssertionCount(1);
291 return new WebAssert($this->getSession($name), $this->baseUrl);
295 * Retrieves a Drupal path or an absolute path.
297 * @param string|\Drupal\Core\Url $path
298 * Drupal path or URL to load into Mink controlled browser.
299 * @param array $options
300 * (optional) Options to be forwarded to the url generator.
301 * @param string[] $headers
302 * An array containing additional HTTP request headers, the array keys are
303 * the header names and the array values the header values. This is useful
304 * to set for example the "Accept-Language" header for requesting the page
305 * in a different language. Note that not all headers are supported, for
306 * example the "Accept" header is always overridden by the browser. For
307 * testing REST APIs it is recommended to obtain a separate HTTP client
308 * using getHttpClient() and performing requests that way.
311 * The retrieved HTML string, also available as $this->getRawContent()
313 * @see \Drupal\Tests\BrowserTestBase::getHttpClient()
315 protected function drupalGet($path, array $options = [], array $headers = []) {
316 $options['absolute'] = TRUE;
317 $url = $this->buildUrl($path, $options);
319 $session = $this->getSession();
321 $this->prepareRequest();
322 foreach ($headers as $header_name => $header_value) {
323 $session->setRequestHeader($header_name, $header_value);
326 $session->visit($url);
327 $out = $session->getPage()->getContent();
329 // Ensure that any changes to variables in the other thread are picked up.
330 $this->refreshVariables();
332 // Replace original page output with new output from redirected page(s).
333 if ($new = $this->checkForMetaRefresh()) {
335 // We are finished with all meta refresh redirects, so reset the counter.
336 $this->metaRefreshCount = 0;
339 // Log only for JavascriptTestBase tests because for Goutte we log with
340 // ::getResponseLogHandler.
341 if ($this->htmlOutputEnabled && !($this->getSession()->getDriver() instanceof GoutteDriver)) {
342 $html_output = 'GET request to: ' . $url .
343 '<hr />Ending URL: ' . $this->getSession()->getCurrentUrl();
344 $html_output .= '<hr />' . $out;
345 $html_output .= $this->getHtmlOutputHeaders();
346 $this->htmlOutput($html_output);
353 * Builds an a absolute URL from a system path or a URL object.
355 * @param string|\Drupal\Core\Url $path
356 * A system path or a URL.
357 * @param array $options
358 * Options to be passed to Url::fromUri().
361 * An absolute URL string.
363 protected function buildUrl($path, array $options = []) {
364 if ($path instanceof Url) {
365 $url_options = $path->getOptions();
366 $options = $url_options + $options;
367 $path->setOptions($options);
368 return $path->setAbsolute()->toString();
370 // The URL generator service is not necessarily available yet; e.g., in
371 // interactive installer tests.
372 elseif (\Drupal::hasService('url_generator')) {
373 $force_internal = isset($options['external']) && $options['external'] == FALSE;
374 if (!$force_internal && UrlHelper::isExternal($path)) {
375 return Url::fromUri($path, $options)->toString();
378 $uri = $path === '<front>' ? 'base:/' : 'base:/' . $path;
379 // Path processing is needed for language prefixing. Skip it when a
380 // path that may look like an external URL is being used as internal.
381 $options['path_processing'] = !$force_internal;
382 return Url::fromUri($uri, $options)
388 return $this->getAbsoluteUrl($path);
393 * Takes a path and returns an absolute path.
395 * @param string $path
396 * A path from the Mink controlled browser content.
399 * The $path with $base_url prepended, if necessary.
401 protected function getAbsoluteUrl($path) {
402 global $base_url, $base_path;
404 $parts = parse_url($path);
405 if (empty($parts['host'])) {
406 // Ensure that we have a string (and no xpath object).
407 $path = (string) $path;
408 // Strip $base_path, if existent.
409 $length = strlen($base_path);
410 if (substr($path, 0, $length) === $base_path) {
411 $path = substr($path, $length);
413 // Ensure that we have an absolute path.
414 if (empty($path) || $path[0] !== '/') {
417 // Finally, prepend the $base_url.
418 $path = $base_url . $path;
424 * Prepare for a request to testing site.
426 * The testing site is protected via a SIMPLETEST_USER_AGENT cookie that is
427 * checked by drupal_valid_test_ua().
429 * @see drupal_valid_test_ua()
431 protected function prepareRequest() {
432 $session = $this->getSession();
433 $session->setCookie('SIMPLETEST_USER_AGENT', drupal_generate_test_ua($this->databasePrefix));
437 * Returns whether a given user account is logged in.
439 * @param \Drupal\Core\Session\AccountInterface $account
440 * The user account object to check.
443 * Return TRUE if the user is logged in, FALSE otherwise.
445 protected function drupalUserIsLoggedIn(AccountInterface $account) {
448 if (isset($account->sessionId)) {
449 $session_handler = \Drupal::service('session_handler.storage');
450 $logged_in = (bool) $session_handler->read($account->sessionId);
457 * Clicks the element with the given CSS selector.
459 * @param string $css_selector
460 * The CSS selector identifying the element to click.
462 protected function click($css_selector) {
463 $starting_url = $this->getSession()->getCurrentUrl();
464 $this->getSession()->getDriver()->click($this->cssSelectToXpath($css_selector));
465 // Log only for JavascriptTestBase tests because for Goutte we log with
466 // ::getResponseLogHandler.
467 if ($this->htmlOutputEnabled && !($this->getSession()->getDriver() instanceof GoutteDriver)) {
468 $out = $this->getSession()->getPage()->getContent();
470 'Clicked element with CSS selector: ' . $css_selector .
471 '<hr />Starting URL: ' . $starting_url .
472 '<hr />Ending URL: ' . $this->getSession()->getCurrentUrl();
473 $html_output .= '<hr />' . $out;
474 $html_output .= $this->getHtmlOutputHeaders();
475 $this->htmlOutput($html_output);
480 * Follows a link by complete name.
482 * Will click the first link found with this link text.
484 * If the link is discovered and clicked, the test passes. Fail otherwise.
486 * @param string|\Drupal\Component\Render\MarkupInterface $label
487 * Text between the anchor tags.
489 * (optional) The index number for cases where multiple links have the same
490 * text. Defaults to 0.
492 protected function clickLink($label, $index = 0) {
493 $label = (string) $label;
494 $links = $this->getSession()->getPage()->findAll('named', ['link', $label]);
495 $this->assertArrayHasKey($index, $links, 'The link ' . $label . ' was not found on the page.');
496 $links[$index]->click();
500 * Retrieves the plain-text content from the current page.
502 protected function getTextContent() {
503 return $this->getSession()->getPage()->getText();
507 * Get the current URL from the browser.
512 protected function getUrl() {
513 return $this->getSession()->getCurrentUrl();
517 * Checks for meta refresh tag and if found call drupalGet() recursively.
519 * This function looks for the http-equiv attribute to be set to "Refresh" and
520 * is case-insensitive.
522 * @return string|false
523 * Either the new page content or FALSE.
525 protected function checkForMetaRefresh() {
526 $refresh = $this->cssSelect('meta[http-equiv="Refresh"], meta[http-equiv="refresh"]');
527 if (!empty($refresh) && (!isset($this->maximumMetaRefreshCount) || $this->metaRefreshCount < $this->maximumMetaRefreshCount)) {
528 // Parse the content attribute of the meta tag for the format:
529 // "[delay]: URL=[page_to_redirect_to]".
530 if (preg_match('/\d+;\s*URL=(?<url>.*)/i', $refresh[0]->getAttribute('content'), $match)) {
531 $this->metaRefreshCount++;
532 return $this->drupalGet($this->getAbsoluteUrl(Html::decodeEntities($match['url'])));
539 * Searches elements using a CSS selector in the raw content.
541 * The search is relative to the root element (HTML tag normally) of the page.
543 * @param string $selector
544 * CSS selector to use in the search.
546 * @return \Behat\Mink\Element\NodeElement[]
547 * The list of elements on the page that match the selector.
549 protected function cssSelect($selector) {
550 return $this->getSession()->getPage()->findAll('css', $selector);