Mercurial > hg > isophonics-drupal-site
comparison core/modules/simpletest/src/WebTestBase.php @ 0:4c8ae668cc8c
Initial import (non-working)
author | Chris Cannam |
---|---|
date | Wed, 29 Nov 2017 16:09:58 +0000 |
parents | |
children | 7a779792577d |
comparison
equal
deleted
inserted
replaced
-1:000000000000 | 0:4c8ae668cc8c |
---|---|
1 <?php | |
2 | |
3 namespace Drupal\simpletest; | |
4 | |
5 use Drupal\block\Entity\Block; | |
6 use Drupal\Component\Serialization\Json; | |
7 use Drupal\Component\Utility\Html; | |
8 use Drupal\Component\Utility\NestedArray; | |
9 use Drupal\Component\Utility\UrlHelper; | |
10 use Drupal\Component\Utility\SafeMarkup; | |
11 use Drupal\Core\EventSubscriber\AjaxResponseSubscriber; | |
12 use Drupal\Core\EventSubscriber\MainContentViewSubscriber; | |
13 use Drupal\Core\Session\AccountInterface; | |
14 use Drupal\Core\Session\AnonymousUserSession; | |
15 use Drupal\Core\Test\AssertMailTrait; | |
16 use Drupal\Core\Test\FunctionalTestSetupTrait; | |
17 use Drupal\Core\Url; | |
18 use Drupal\system\Tests\Cache\AssertPageCacheContextsAndTagsTrait; | |
19 use Drupal\Tests\EntityViewTrait; | |
20 use Drupal\Tests\block\Traits\BlockCreationTrait as BaseBlockCreationTrait; | |
21 use Drupal\Tests\node\Traits\ContentTypeCreationTrait; | |
22 use Drupal\Tests\node\Traits\NodeCreationTrait; | |
23 use Drupal\Tests\Traits\Core\CronRunTrait; | |
24 use Drupal\Tests\TestFileCreationTrait; | |
25 use Drupal\Tests\user\Traits\UserCreationTrait; | |
26 use Drupal\Tests\XdebugRequestTrait; | |
27 use Zend\Diactoros\Uri; | |
28 | |
29 /** | |
30 * Test case for typical Drupal tests. | |
31 * | |
32 * @ingroup testing | |
33 */ | |
34 abstract class WebTestBase extends TestBase { | |
35 | |
36 use FunctionalTestSetupTrait; | |
37 use AssertContentTrait; | |
38 use TestFileCreationTrait { | |
39 getTestFiles as drupalGetTestFiles; | |
40 compareFiles as drupalCompareFiles; | |
41 } | |
42 use AssertPageCacheContextsAndTagsTrait; | |
43 use BaseBlockCreationTrait { | |
44 placeBlock as drupalPlaceBlock; | |
45 } | |
46 use ContentTypeCreationTrait { | |
47 createContentType as drupalCreateContentType; | |
48 } | |
49 use CronRunTrait; | |
50 use AssertMailTrait { | |
51 getMails as drupalGetMails; | |
52 } | |
53 use NodeCreationTrait { | |
54 getNodeByTitle as drupalGetNodeByTitle; | |
55 createNode as drupalCreateNode; | |
56 } | |
57 use UserCreationTrait { | |
58 createUser as drupalCreateUser; | |
59 createRole as drupalCreateRole; | |
60 createAdminRole as drupalCreateAdminRole; | |
61 } | |
62 | |
63 use XdebugRequestTrait; | |
64 use EntityViewTrait { | |
65 buildEntityView as drupalBuildEntityView; | |
66 } | |
67 | |
68 /** | |
69 * The profile to install as a basis for testing. | |
70 * | |
71 * @var string | |
72 */ | |
73 protected $profile = 'testing'; | |
74 | |
75 /** | |
76 * The URL currently loaded in the internal browser. | |
77 * | |
78 * @var string | |
79 */ | |
80 protected $url; | |
81 | |
82 /** | |
83 * The handle of the current cURL connection. | |
84 * | |
85 * @var resource | |
86 */ | |
87 protected $curlHandle; | |
88 | |
89 /** | |
90 * Whether or not to assert the presence of the X-Drupal-Ajax-Token. | |
91 * | |
92 * @var bool | |
93 */ | |
94 protected $assertAjaxHeader = TRUE; | |
95 | |
96 /** | |
97 * The headers of the page currently loaded in the internal browser. | |
98 * | |
99 * @var Array | |
100 */ | |
101 protected $headers; | |
102 | |
103 /** | |
104 * The cookies of the page currently loaded in the internal browser. | |
105 * | |
106 * @var array | |
107 */ | |
108 protected $cookies = []; | |
109 | |
110 /** | |
111 * Indicates that headers should be dumped if verbose output is enabled. | |
112 * | |
113 * Headers are dumped to verbose by drupalGet(), drupalHead(), and | |
114 * drupalPostForm(). | |
115 * | |
116 * @var bool | |
117 */ | |
118 protected $dumpHeaders = FALSE; | |
119 | |
120 /** | |
121 * The current user logged in using the internal browser. | |
122 * | |
123 * @var \Drupal\Core\Session\AccountInterface|bool | |
124 */ | |
125 protected $loggedInUser = FALSE; | |
126 | |
127 /** | |
128 * The current cookie file used by cURL. | |
129 * | |
130 * We do not reuse the cookies in further runs, so we do not need a file | |
131 * but we still need cookie handling, so we set the jar to NULL. | |
132 */ | |
133 protected $cookieFile = NULL; | |
134 | |
135 /** | |
136 * Additional cURL options. | |
137 * | |
138 * \Drupal\simpletest\WebTestBase itself never sets this but always obeys what | |
139 * is set. | |
140 */ | |
141 protected $additionalCurlOptions = []; | |
142 | |
143 /** | |
144 * The original batch, before it was changed for testing purposes. | |
145 * | |
146 * @var array | |
147 */ | |
148 protected $originalBatch; | |
149 | |
150 /** | |
151 * The original user, before it was changed to a clean uid = 1 for testing. | |
152 * | |
153 * @var object | |
154 */ | |
155 protected $originalUser = NULL; | |
156 | |
157 /** | |
158 * The original shutdown handlers array, before it was cleaned for testing. | |
159 * | |
160 * @var array | |
161 */ | |
162 protected $originalShutdownCallbacks = []; | |
163 | |
164 /** | |
165 * The current session ID, if available. | |
166 */ | |
167 protected $sessionId = NULL; | |
168 | |
169 /** | |
170 * The maximum number of redirects to follow when handling responses. | |
171 */ | |
172 protected $maximumRedirects = 5; | |
173 | |
174 /** | |
175 * The number of redirects followed during the handling of a request. | |
176 */ | |
177 protected $redirectCount; | |
178 | |
179 | |
180 /** | |
181 * The number of meta refresh redirects to follow, or NULL if unlimited. | |
182 * | |
183 * @var null|int | |
184 */ | |
185 protected $maximumMetaRefreshCount = NULL; | |
186 | |
187 /** | |
188 * The number of meta refresh redirects followed during ::drupalGet(). | |
189 * | |
190 * @var int | |
191 */ | |
192 protected $metaRefreshCount = 0; | |
193 | |
194 /** | |
195 * Cookies to set on curl requests. | |
196 * | |
197 * @var array | |
198 */ | |
199 protected $curlCookies = []; | |
200 | |
201 /** | |
202 * An array of custom translations suitable for drupal_rewrite_settings(). | |
203 * | |
204 * @var array | |
205 */ | |
206 protected $customTranslations; | |
207 | |
208 /** | |
209 * Constructor for \Drupal\simpletest\WebTestBase. | |
210 */ | |
211 public function __construct($test_id = NULL) { | |
212 parent::__construct($test_id); | |
213 $this->skipClasses[__CLASS__] = TRUE; | |
214 $this->classLoader = require DRUPAL_ROOT . '/autoload.php'; | |
215 } | |
216 | |
217 /** | |
218 * Checks to see whether a block appears on the page. | |
219 * | |
220 * @param \Drupal\block\Entity\Block $block | |
221 * The block entity to find on the page. | |
222 */ | |
223 protected function assertBlockAppears(Block $block) { | |
224 $result = $this->findBlockInstance($block); | |
225 $this->assertTrue(!empty($result), format_string('Ensure the block @id appears on the page', ['@id' => $block->id()])); | |
226 } | |
227 | |
228 /** | |
229 * Checks to see whether a block does not appears on the page. | |
230 * | |
231 * @param \Drupal\block\Entity\Block $block | |
232 * The block entity to find on the page. | |
233 */ | |
234 protected function assertNoBlockAppears(Block $block) { | |
235 $result = $this->findBlockInstance($block); | |
236 $this->assertFalse(!empty($result), format_string('Ensure the block @id does not appear on the page', ['@id' => $block->id()])); | |
237 } | |
238 | |
239 /** | |
240 * Find a block instance on the page. | |
241 * | |
242 * @param \Drupal\block\Entity\Block $block | |
243 * The block entity to find on the page. | |
244 * | |
245 * @return array | |
246 * The result from the xpath query. | |
247 */ | |
248 protected function findBlockInstance(Block $block) { | |
249 return $this->xpath('//div[@id = :id]', [':id' => 'block-' . $block->id()]); | |
250 } | |
251 | |
252 /** | |
253 * Log in a user with the internal browser. | |
254 * | |
255 * If a user is already logged in, then the current user is logged out before | |
256 * logging in the specified user. | |
257 * | |
258 * Please note that neither the current user nor the passed-in user object is | |
259 * populated with data of the logged in user. If you need full access to the | |
260 * user object after logging in, it must be updated manually. If you also need | |
261 * access to the plain-text password of the user (set by drupalCreateUser()), | |
262 * e.g. to log in the same user again, then it must be re-assigned manually. | |
263 * For example: | |
264 * @code | |
265 * // Create a user. | |
266 * $account = $this->drupalCreateUser(array()); | |
267 * $this->drupalLogin($account); | |
268 * // Load real user object. | |
269 * $pass_raw = $account->pass_raw; | |
270 * $account = User::load($account->id()); | |
271 * $account->pass_raw = $pass_raw; | |
272 * @endcode | |
273 * | |
274 * @param \Drupal\Core\Session\AccountInterface $account | |
275 * User object representing the user to log in. | |
276 * | |
277 * @see drupalCreateUser() | |
278 */ | |
279 protected function drupalLogin(AccountInterface $account) { | |
280 if ($this->loggedInUser) { | |
281 $this->drupalLogout(); | |
282 } | |
283 | |
284 $edit = [ | |
285 'name' => $account->getUsername(), | |
286 'pass' => $account->pass_raw | |
287 ]; | |
288 $this->drupalPostForm('user/login', $edit, t('Log in')); | |
289 | |
290 // @see WebTestBase::drupalUserIsLoggedIn() | |
291 if (isset($this->sessionId)) { | |
292 $account->session_id = $this->sessionId; | |
293 } | |
294 $pass = $this->assert($this->drupalUserIsLoggedIn($account), format_string('User %name successfully logged in.', ['%name' => $account->getUsername()]), 'User login'); | |
295 if ($pass) { | |
296 $this->loggedInUser = $account; | |
297 $this->container->get('current_user')->setAccount($account); | |
298 } | |
299 } | |
300 | |
301 /** | |
302 * Returns whether a given user account is logged in. | |
303 * | |
304 * @param \Drupal\user\UserInterface $account | |
305 * The user account object to check. | |
306 */ | |
307 protected function drupalUserIsLoggedIn($account) { | |
308 $logged_in = FALSE; | |
309 | |
310 if (isset($account->session_id)) { | |
311 $session_handler = $this->container->get('session_handler.storage'); | |
312 $logged_in = (bool) $session_handler->read($account->session_id); | |
313 } | |
314 | |
315 return $logged_in; | |
316 } | |
317 | |
318 /** | |
319 * Logs a user out of the internal browser and confirms. | |
320 * | |
321 * Confirms logout by checking the login page. | |
322 */ | |
323 protected function drupalLogout() { | |
324 // Make a request to the logout page, and redirect to the user page, the | |
325 // idea being if you were properly logged out you should be seeing a login | |
326 // screen. | |
327 $this->drupalGet('user/logout', ['query' => ['destination' => 'user/login']]); | |
328 $this->assertResponse(200, 'User was logged out.'); | |
329 $pass = $this->assertField('name', 'Username field found.', 'Logout'); | |
330 $pass = $pass && $this->assertField('pass', 'Password field found.', 'Logout'); | |
331 | |
332 if ($pass) { | |
333 // @see WebTestBase::drupalUserIsLoggedIn() | |
334 unset($this->loggedInUser->session_id); | |
335 $this->loggedInUser = FALSE; | |
336 $this->container->get('current_user')->setAccount(new AnonymousUserSession()); | |
337 } | |
338 } | |
339 | |
340 /** | |
341 * Sets up a Drupal site for running functional and integration tests. | |
342 * | |
343 * Installs Drupal with the installation profile specified in | |
344 * \Drupal\simpletest\WebTestBase::$profile into the prefixed database. | |
345 * | |
346 * Afterwards, installs any additional modules specified in the static | |
347 * \Drupal\simpletest\WebTestBase::$modules property of each class in the | |
348 * class hierarchy. | |
349 * | |
350 * After installation all caches are flushed and several configuration values | |
351 * are reset to the values of the parent site executing the test, since the | |
352 * default values may be incompatible with the environment in which tests are | |
353 * being executed. | |
354 */ | |
355 protected function setUp() { | |
356 // Set an explicit time zone to not rely on the system one, which may vary | |
357 // from setup to setup. The Australia/Sydney time zone is chosen so all | |
358 // tests are run using an edge case scenario (UTC+10 and DST). This choice | |
359 // is made to prevent time zone related regressions and reduce the | |
360 // fragility of the testing system in general. This is also set in config in | |
361 // \Drupal\simpletest\WebTestBase::initConfig(). | |
362 date_default_timezone_set('Australia/Sydney'); | |
363 | |
364 // Preserve original batch for later restoration. | |
365 $this->setBatch(); | |
366 | |
367 // Initialize user 1 and session name. | |
368 $this->initUserSession(); | |
369 | |
370 // Prepare the child site settings. | |
371 $this->prepareSettings(); | |
372 | |
373 // Execute the non-interactive installer. | |
374 $this->doInstall(); | |
375 | |
376 // Import new settings.php written by the installer. | |
377 $this->initSettings(); | |
378 | |
379 // Initialize the request and container post-install. | |
380 $container = $this->initKernel(\Drupal::request()); | |
381 | |
382 // Initialize and override certain configurations. | |
383 $this->initConfig($container); | |
384 | |
385 // Collect modules to install. | |
386 $this->installModulesFromClassProperty($container); | |
387 | |
388 // Restore the original batch. | |
389 $this->restoreBatch(); | |
390 | |
391 // Reset/rebuild everything. | |
392 $this->rebuildAll(); | |
393 } | |
394 | |
395 /** | |
396 * Preserve the original batch, and instantiate the test batch. | |
397 */ | |
398 protected function setBatch() { | |
399 // When running tests through the Simpletest UI (vs. on the command line), | |
400 // Simpletest's batch conflicts with the installer's batch. Batch API does | |
401 // not support the concept of nested batches (in which the nested is not | |
402 // progressive), so we need to temporarily pretend there was no batch. | |
403 // Backup the currently running Simpletest batch. | |
404 $this->originalBatch = batch_get(); | |
405 | |
406 // Reset the static batch to remove Simpletest's batch operations. | |
407 $batch = &batch_get(); | |
408 $batch = []; | |
409 } | |
410 | |
411 /** | |
412 * Restore the original batch. | |
413 * | |
414 * @see ::setBatch | |
415 */ | |
416 protected function restoreBatch() { | |
417 // Restore the original Simpletest batch. | |
418 $batch = &batch_get(); | |
419 $batch = $this->originalBatch; | |
420 } | |
421 | |
422 /** | |
423 * Queues custom translations to be written to settings.php. | |
424 * | |
425 * Use WebTestBase::writeCustomTranslations() to apply and write the queued | |
426 * translations. | |
427 * | |
428 * @param string $langcode | |
429 * The langcode to add translations for. | |
430 * @param array $values | |
431 * Array of values containing the untranslated string and its translation. | |
432 * For example: | |
433 * @code | |
434 * array( | |
435 * '' => array('Sunday' => 'domingo'), | |
436 * 'Long month name' => array('March' => 'marzo'), | |
437 * ); | |
438 * @endcode | |
439 * Pass an empty array to remove all existing custom translations for the | |
440 * given $langcode. | |
441 */ | |
442 protected function addCustomTranslations($langcode, array $values) { | |
443 // If $values is empty, then the test expects all custom translations to be | |
444 // cleared. | |
445 if (empty($values)) { | |
446 $this->customTranslations[$langcode] = []; | |
447 } | |
448 // Otherwise, $values are expected to be merged into previously passed | |
449 // values, while retaining keys that are not explicitly set. | |
450 else { | |
451 foreach ($values as $context => $translations) { | |
452 foreach ($translations as $original => $translation) { | |
453 $this->customTranslations[$langcode][$context][$original] = $translation; | |
454 } | |
455 } | |
456 } | |
457 } | |
458 | |
459 /** | |
460 * Writes custom translations to the test site's settings.php. | |
461 * | |
462 * Use TestBase::addCustomTranslations() to queue custom translations before | |
463 * calling this method. | |
464 */ | |
465 protected function writeCustomTranslations() { | |
466 $settings = []; | |
467 foreach ($this->customTranslations as $langcode => $values) { | |
468 $settings_key = 'locale_custom_strings_' . $langcode; | |
469 | |
470 // Update in-memory settings directly. | |
471 $this->settingsSet($settings_key, $values); | |
472 | |
473 $settings['settings'][$settings_key] = (object) [ | |
474 'value' => $values, | |
475 'required' => TRUE, | |
476 ]; | |
477 } | |
478 // Only rewrite settings if there are any translation changes to write. | |
479 if (!empty($settings)) { | |
480 $this->writeSettings($settings); | |
481 } | |
482 } | |
483 | |
484 /** | |
485 * Cleans up after testing. | |
486 * | |
487 * Deletes created files and temporary files directory, deletes the tables | |
488 * created by setUp(), and resets the database prefix. | |
489 */ | |
490 protected function tearDown() { | |
491 // Destroy the testing kernel. | |
492 if (isset($this->kernel)) { | |
493 $this->kernel->shutdown(); | |
494 } | |
495 parent::tearDown(); | |
496 | |
497 // Ensure that the maximum meta refresh count is reset. | |
498 $this->maximumMetaRefreshCount = NULL; | |
499 | |
500 // Ensure that internal logged in variable and cURL options are reset. | |
501 $this->loggedInUser = FALSE; | |
502 $this->additionalCurlOptions = []; | |
503 | |
504 // Close the CURL handler and reset the cookies array used for upgrade | |
505 // testing so test classes containing multiple tests are not polluted. | |
506 $this->curlClose(); | |
507 $this->curlCookies = []; | |
508 $this->cookies = []; | |
509 } | |
510 | |
511 /** | |
512 * Initializes the cURL connection. | |
513 * | |
514 * If the simpletest_httpauth_credentials variable is set, this function will | |
515 * add HTTP authentication headers. This is necessary for testing sites that | |
516 * are protected by login credentials from public access. | |
517 * See the description of $curl_options for other options. | |
518 */ | |
519 protected function curlInitialize() { | |
520 global $base_url; | |
521 | |
522 if (!isset($this->curlHandle)) { | |
523 $this->curlHandle = curl_init(); | |
524 | |
525 // Some versions/configurations of cURL break on a NULL cookie jar, so | |
526 // supply a real file. | |
527 if (empty($this->cookieFile)) { | |
528 $this->cookieFile = $this->publicFilesDirectory . '/cookie.jar'; | |
529 } | |
530 | |
531 $curl_options = [ | |
532 CURLOPT_COOKIEJAR => $this->cookieFile, | |
533 CURLOPT_URL => $base_url, | |
534 CURLOPT_FOLLOWLOCATION => FALSE, | |
535 CURLOPT_RETURNTRANSFER => TRUE, | |
536 // Required to make the tests run on HTTPS. | |
537 CURLOPT_SSL_VERIFYPEER => FALSE, | |
538 // Required to make the tests run on HTTPS. | |
539 CURLOPT_SSL_VERIFYHOST => FALSE, | |
540 CURLOPT_HEADERFUNCTION => [&$this, 'curlHeaderCallback'], | |
541 CURLOPT_USERAGENT => $this->databasePrefix, | |
542 // Disable support for the @ prefix for uploading files. | |
543 CURLOPT_SAFE_UPLOAD => TRUE, | |
544 ]; | |
545 if (isset($this->httpAuthCredentials)) { | |
546 $curl_options[CURLOPT_HTTPAUTH] = $this->httpAuthMethod; | |
547 $curl_options[CURLOPT_USERPWD] = $this->httpAuthCredentials; | |
548 } | |
549 // curl_setopt_array() returns FALSE if any of the specified options | |
550 // cannot be set, and stops processing any further options. | |
551 $result = curl_setopt_array($this->curlHandle, $this->additionalCurlOptions + $curl_options); | |
552 if (!$result) { | |
553 throw new \UnexpectedValueException('One or more cURL options could not be set.'); | |
554 } | |
555 } | |
556 // We set the user agent header on each request so as to use the current | |
557 // time and a new uniqid. | |
558 curl_setopt($this->curlHandle, CURLOPT_USERAGENT, drupal_generate_test_ua($this->databasePrefix)); | |
559 } | |
560 | |
561 /** | |
562 * Initializes and executes a cURL request. | |
563 * | |
564 * @param $curl_options | |
565 * An associative array of cURL options to set, where the keys are constants | |
566 * defined by the cURL library. For a list of valid options, see | |
567 * http://php.net/manual/function.curl-setopt.php | |
568 * @param $redirect | |
569 * FALSE if this is an initial request, TRUE if this request is the result | |
570 * of a redirect. | |
571 * | |
572 * @return | |
573 * The content returned from the call to curl_exec(). | |
574 * | |
575 * @see curlInitialize() | |
576 */ | |
577 protected function curlExec($curl_options, $redirect = FALSE) { | |
578 $this->curlInitialize(); | |
579 | |
580 if (!empty($curl_options[CURLOPT_URL])) { | |
581 // cURL incorrectly handles URLs with a fragment by including the | |
582 // fragment in the request to the server, causing some web servers | |
583 // to reject the request citing "400 - Bad Request". To prevent | |
584 // this, we strip the fragment from the request. | |
585 // TODO: Remove this for Drupal 8, since fixed in curl 7.20.0. | |
586 if (strpos($curl_options[CURLOPT_URL], '#')) { | |
587 $original_url = $curl_options[CURLOPT_URL]; | |
588 $curl_options[CURLOPT_URL] = strtok($curl_options[CURLOPT_URL], '#'); | |
589 } | |
590 } | |
591 | |
592 $url = empty($curl_options[CURLOPT_URL]) ? curl_getinfo($this->curlHandle, CURLINFO_EFFECTIVE_URL) : $curl_options[CURLOPT_URL]; | |
593 | |
594 if (!empty($curl_options[CURLOPT_POST])) { | |
595 // This is a fix for the Curl library to prevent Expect: 100-continue | |
596 // headers in POST requests, that may cause unexpected HTTP response | |
597 // codes from some webservers (like lighttpd that returns a 417 error | |
598 // code). It is done by setting an empty "Expect" header field that is | |
599 // not overwritten by Curl. | |
600 $curl_options[CURLOPT_HTTPHEADER][] = 'Expect:'; | |
601 } | |
602 | |
603 $cookies = []; | |
604 if (!empty($this->curlCookies)) { | |
605 $cookies = $this->curlCookies; | |
606 } | |
607 | |
608 foreach ($this->extractCookiesFromRequest(\Drupal::request()) as $cookie_name => $values) { | |
609 foreach ($values as $value) { | |
610 $cookies[] = $cookie_name . '=' . $value; | |
611 } | |
612 } | |
613 | |
614 // Merge additional cookies in. | |
615 if (!empty($cookies)) { | |
616 $curl_options += [ | |
617 CURLOPT_COOKIE => '', | |
618 ]; | |
619 // Ensure any existing cookie data string ends with the correct separator. | |
620 if (!empty($curl_options[CURLOPT_COOKIE])) { | |
621 $curl_options[CURLOPT_COOKIE] = rtrim($curl_options[CURLOPT_COOKIE], '; ') . '; '; | |
622 } | |
623 $curl_options[CURLOPT_COOKIE] .= implode('; ', $cookies) . ';'; | |
624 } | |
625 | |
626 curl_setopt_array($this->curlHandle, $this->additionalCurlOptions + $curl_options); | |
627 | |
628 if (!$redirect) { | |
629 // Reset headers, the session ID and the redirect counter. | |
630 $this->sessionId = NULL; | |
631 $this->headers = []; | |
632 $this->redirectCount = 0; | |
633 } | |
634 | |
635 $content = curl_exec($this->curlHandle); | |
636 $status = curl_getinfo($this->curlHandle, CURLINFO_HTTP_CODE); | |
637 | |
638 // cURL incorrectly handles URLs with fragments, so instead of | |
639 // letting cURL handle redirects we take of them ourselves to | |
640 // to prevent fragments being sent to the web server as part | |
641 // of the request. | |
642 // TODO: Remove this for Drupal 8, since fixed in curl 7.20.0. | |
643 if (in_array($status, [300, 301, 302, 303, 305, 307]) && $this->redirectCount < $this->maximumRedirects) { | |
644 if ($this->drupalGetHeader('location')) { | |
645 $this->redirectCount++; | |
646 $curl_options = []; | |
647 $curl_options[CURLOPT_URL] = $this->drupalGetHeader('location'); | |
648 $curl_options[CURLOPT_HTTPGET] = TRUE; | |
649 return $this->curlExec($curl_options, TRUE); | |
650 } | |
651 } | |
652 | |
653 $this->setRawContent($content); | |
654 $this->url = isset($original_url) ? $original_url : curl_getinfo($this->curlHandle, CURLINFO_EFFECTIVE_URL); | |
655 | |
656 $message_vars = [ | |
657 '@method' => !empty($curl_options[CURLOPT_NOBODY]) ? 'HEAD' : (empty($curl_options[CURLOPT_POSTFIELDS]) ? 'GET' : 'POST'), | |
658 '@url' => isset($original_url) ? $original_url : $url, | |
659 '@status' => $status, | |
660 '@length' => format_size(strlen($this->getRawContent())) | |
661 ]; | |
662 $message = SafeMarkup::format('@method @url returned @status (@length).', $message_vars); | |
663 $this->assertTrue($this->getRawContent() !== FALSE, $message, 'Browser'); | |
664 return $this->getRawContent(); | |
665 } | |
666 | |
667 /** | |
668 * Reads headers and registers errors received from the tested site. | |
669 * | |
670 * @param $curlHandler | |
671 * The cURL handler. | |
672 * @param $header | |
673 * An header. | |
674 * | |
675 * @see _drupal_log_error() | |
676 */ | |
677 protected function curlHeaderCallback($curlHandler, $header) { | |
678 // Header fields can be extended over multiple lines by preceding each | |
679 // extra line with at least one SP or HT. They should be joined on receive. | |
680 // Details are in RFC2616 section 4. | |
681 if ($header[0] == ' ' || $header[0] == "\t") { | |
682 // Normalize whitespace between chucks. | |
683 $this->headers[] = array_pop($this->headers) . ' ' . trim($header); | |
684 } | |
685 else { | |
686 $this->headers[] = $header; | |
687 } | |
688 | |
689 // Errors are being sent via X-Drupal-Assertion-* headers, | |
690 // generated by _drupal_log_error() in the exact form required | |
691 // by \Drupal\simpletest\WebTestBase::error(). | |
692 if (preg_match('/^X-Drupal-Assertion-[0-9]+: (.*)$/', $header, $matches)) { | |
693 // Call \Drupal\simpletest\WebTestBase::error() with the parameters from | |
694 // the header. | |
695 call_user_func_array([&$this, 'error'], unserialize(urldecode($matches[1]))); | |
696 } | |
697 | |
698 // Save cookies. | |
699 if (preg_match('/^Set-Cookie: ([^=]+)=(.+)/', $header, $matches)) { | |
700 $name = $matches[1]; | |
701 $parts = array_map('trim', explode(';', $matches[2])); | |
702 $value = array_shift($parts); | |
703 $this->cookies[$name] = ['value' => $value, 'secure' => in_array('secure', $parts)]; | |
704 if ($name === $this->getSessionName()) { | |
705 if ($value != 'deleted') { | |
706 $this->sessionId = $value; | |
707 } | |
708 else { | |
709 $this->sessionId = NULL; | |
710 } | |
711 } | |
712 } | |
713 | |
714 // This is required by cURL. | |
715 return strlen($header); | |
716 } | |
717 | |
718 /** | |
719 * Close the cURL handler and unset the handler. | |
720 */ | |
721 protected function curlClose() { | |
722 if (isset($this->curlHandle)) { | |
723 curl_close($this->curlHandle); | |
724 unset($this->curlHandle); | |
725 } | |
726 } | |
727 | |
728 /** | |
729 * Returns whether the test is being executed from within a test site. | |
730 * | |
731 * Mainly used by recursive tests (i.e. to test the testing framework). | |
732 * | |
733 * @return bool | |
734 * TRUE if this test was instantiated in a request within the test site, | |
735 * FALSE otherwise. | |
736 * | |
737 * @see \Drupal\Core\DrupalKernel::bootConfiguration() | |
738 */ | |
739 protected function isInChildSite() { | |
740 return DRUPAL_TEST_IN_CHILD_SITE; | |
741 } | |
742 | |
743 /** | |
744 * Retrieves a Drupal path or an absolute path. | |
745 * | |
746 * @param \Drupal\Core\Url|string $path | |
747 * Drupal path or URL to load into internal browser | |
748 * @param $options | |
749 * Options to be forwarded to the url generator. | |
750 * @param $headers | |
751 * An array containing additional HTTP request headers, each formatted as | |
752 * "name: value". | |
753 * | |
754 * @return string | |
755 * The retrieved HTML string, also available as $this->getRawContent() | |
756 */ | |
757 protected function drupalGet($path, array $options = [], array $headers = []) { | |
758 // We re-using a CURL connection here. If that connection still has certain | |
759 // options set, it might change the GET into a POST. Make sure we clear out | |
760 // previous options. | |
761 $out = $this->curlExec([CURLOPT_HTTPGET => TRUE, CURLOPT_URL => $this->buildUrl($path, $options), CURLOPT_NOBODY => FALSE, CURLOPT_HTTPHEADER => $headers]); | |
762 // Ensure that any changes to variables in the other thread are picked up. | |
763 $this->refreshVariables(); | |
764 | |
765 // Replace original page output with new output from redirected page(s). | |
766 if ($new = $this->checkForMetaRefresh()) { | |
767 $out = $new; | |
768 // We are finished with all meta refresh redirects, so reset the counter. | |
769 $this->metaRefreshCount = 0; | |
770 } | |
771 | |
772 if ($path instanceof Url) { | |
773 $path = $path->setAbsolute()->toString(TRUE)->getGeneratedUrl(); | |
774 } | |
775 | |
776 $verbose = 'GET request to: ' . $path . | |
777 '<hr />Ending URL: ' . $this->getUrl(); | |
778 if ($this->dumpHeaders) { | |
779 $verbose .= '<hr />Headers: <pre>' . Html::escape(var_export(array_map('trim', $this->headers), TRUE)) . '</pre>'; | |
780 } | |
781 $verbose .= '<hr />' . $out; | |
782 | |
783 $this->verbose($verbose); | |
784 return $out; | |
785 } | |
786 | |
787 /** | |
788 * Retrieves a Drupal path or an absolute path and JSON decodes the result. | |
789 * | |
790 * @param \Drupal\Core\Url|string $path | |
791 * Drupal path or URL to request AJAX from. | |
792 * @param array $options | |
793 * Array of URL options. | |
794 * @param array $headers | |
795 * Array of headers. Eg array('Accept: application/vnd.drupal-ajax'). | |
796 * | |
797 * @return array | |
798 * Decoded json. | |
799 */ | |
800 protected function drupalGetJSON($path, array $options = [], array $headers = []) { | |
801 return Json::decode($this->drupalGetWithFormat($path, 'json', $options, $headers)); | |
802 } | |
803 | |
804 /** | |
805 * Retrieves a Drupal path or an absolute path for a given format. | |
806 * | |
807 * @param \Drupal\Core\Url|string $path | |
808 * Drupal path or URL to request given format from. | |
809 * @param string $format | |
810 * The wanted request format. | |
811 * @param array $options | |
812 * Array of URL options. | |
813 * @param array $headers | |
814 * Array of headers. | |
815 * | |
816 * @return mixed | |
817 * The result of the request. | |
818 */ | |
819 protected function drupalGetWithFormat($path, $format, array $options = [], array $headers = []) { | |
820 $options += ['query' => ['_format' => $format]]; | |
821 return $this->drupalGet($path, $options, $headers); | |
822 } | |
823 | |
824 /** | |
825 * Requests a path or URL in drupal_ajax format and JSON-decodes the response. | |
826 * | |
827 * @param \Drupal\Core\Url|string $path | |
828 * Drupal path or URL to request from. | |
829 * @param array $options | |
830 * Array of URL options. | |
831 * @param array $headers | |
832 * Array of headers. | |
833 * | |
834 * @return array | |
835 * Decoded JSON. | |
836 */ | |
837 protected function drupalGetAjax($path, array $options = [], array $headers = []) { | |
838 if (!isset($options['query'][MainContentViewSubscriber::WRAPPER_FORMAT])) { | |
839 $options['query'][MainContentViewSubscriber::WRAPPER_FORMAT] = 'drupal_ajax'; | |
840 } | |
841 return Json::decode($this->drupalGetXHR($path, $options, $headers)); | |
842 } | |
843 | |
844 /** | |
845 * Requests a Drupal path or an absolute path as if it is a XMLHttpRequest. | |
846 * | |
847 * @param \Drupal\Core\Url|string $path | |
848 * Drupal path or URL to request from. | |
849 * @param array $options | |
850 * Array of URL options. | |
851 * @param array $headers | |
852 * Array of headers. | |
853 * | |
854 * @return string | |
855 * The retrieved content. | |
856 */ | |
857 protected function drupalGetXHR($path, array $options = [], array $headers = []) { | |
858 $headers[] = 'X-Requested-With: XMLHttpRequest'; | |
859 return $this->drupalGet($path, $options, $headers); | |
860 } | |
861 | |
862 /** | |
863 * Executes a form submission. | |
864 * | |
865 * It will be done as usual POST request with SimpleBrowser. | |
866 * | |
867 * @param \Drupal\Core\Url|string $path | |
868 * Location of the post form. Either a Drupal path or an absolute path or | |
869 * NULL to post to the current page. For multi-stage forms you can set the | |
870 * path to NULL and have it post to the last received page. Example: | |
871 * | |
872 * @code | |
873 * // First step in form. | |
874 * $edit = array(...); | |
875 * $this->drupalPostForm('some_url', $edit, t('Save')); | |
876 * | |
877 * // Second step in form. | |
878 * $edit = array(...); | |
879 * $this->drupalPostForm(NULL, $edit, t('Save')); | |
880 * @endcode | |
881 * @param $edit | |
882 * Field data in an associative array. Changes the current input fields | |
883 * (where possible) to the values indicated. | |
884 * | |
885 * When working with form tests, the keys for an $edit element should match | |
886 * the 'name' parameter of the HTML of the form. For example, the 'body' | |
887 * field for a node has the following HTML: | |
888 * @code | |
889 * <textarea id="edit-body-und-0-value" class="text-full form-textarea | |
890 * resize-vertical" placeholder="" cols="60" rows="9" | |
891 * name="body[0][value]"></textarea> | |
892 * @endcode | |
893 * When testing this field using an $edit parameter, the code becomes: | |
894 * @code | |
895 * $edit["body[0][value]"] = 'My test value'; | |
896 * @endcode | |
897 * | |
898 * A checkbox can be set to TRUE to be checked and should be set to FALSE to | |
899 * be unchecked. Multiple select fields can be tested using 'name[]' and | |
900 * setting each of the desired values in an array: | |
901 * @code | |
902 * $edit = array(); | |
903 * $edit['name[]'] = array('value1', 'value2'); | |
904 * @endcode | |
905 * @param $submit | |
906 * Value of the submit button whose click is to be emulated. For example, | |
907 * t('Save'). The processing of the request depends on this value. For | |
908 * example, a form may have one button with the value t('Save') and another | |
909 * button with the value t('Delete'), and execute different code depending | |
910 * on which one is clicked. | |
911 * | |
912 * This function can also be called to emulate an Ajax submission. In this | |
913 * case, this value needs to be an array with the following keys: | |
914 * - path: A path to submit the form values to for Ajax-specific processing. | |
915 * - triggering_element: If the value for the 'path' key is a generic Ajax | |
916 * processing path, this needs to be set to the name of the element. If | |
917 * the name doesn't identify the element uniquely, then this should | |
918 * instead be an array with a single key/value pair, corresponding to the | |
919 * element name and value. The \Drupal\Core\Form\FormAjaxResponseBuilder | |
920 * uses this to find the #ajax information for the element, including | |
921 * which specific callback to use for processing the request. | |
922 * | |
923 * This can also be set to NULL in order to emulate an Internet Explorer | |
924 * submission of a form with a single text field, and pressing ENTER in that | |
925 * textfield: under these conditions, no button information is added to the | |
926 * POST data. | |
927 * @param $options | |
928 * Options to be forwarded to the url generator. | |
929 * @param $headers | |
930 * An array containing additional HTTP request headers, each formatted as | |
931 * "name: value". | |
932 * @param $form_html_id | |
933 * (optional) HTML ID of the form to be submitted. On some pages | |
934 * there are many identical forms, so just using the value of the submit | |
935 * button is not enough. For example: 'trigger-node-presave-assign-form'. | |
936 * Note that this is not the Drupal $form_id, but rather the HTML ID of the | |
937 * form, which is typically the same thing but with hyphens replacing the | |
938 * underscores. | |
939 * @param $extra_post | |
940 * (optional) A string of additional data to append to the POST submission. | |
941 * This can be used to add POST data for which there are no HTML fields, as | |
942 * is done by drupalPostAjaxForm(). This string is literally appended to the | |
943 * POST data, so it must already be urlencoded and contain a leading "&" | |
944 * (e.g., "&extra_var1=hello+world&extra_var2=you%26me"). | |
945 */ | |
946 protected function drupalPostForm($path, $edit, $submit, array $options = [], array $headers = [], $form_html_id = NULL, $extra_post = NULL) { | |
947 if (is_object($submit)) { | |
948 // Cast MarkupInterface objects to string. | |
949 $submit = (string) $submit; | |
950 } | |
951 if (is_array($edit)) { | |
952 $edit = $this->castSafeStrings($edit); | |
953 } | |
954 | |
955 $submit_matches = FALSE; | |
956 $ajax = is_array($submit); | |
957 if (isset($path)) { | |
958 $this->drupalGet($path, $options); | |
959 } | |
960 | |
961 if ($this->parse()) { | |
962 $edit_save = $edit; | |
963 // Let's iterate over all the forms. | |
964 $xpath = "//form"; | |
965 if (!empty($form_html_id)) { | |
966 $xpath .= "[@id='" . $form_html_id . "']"; | |
967 } | |
968 $forms = $this->xpath($xpath); | |
969 foreach ($forms as $form) { | |
970 // We try to set the fields of this form as specified in $edit. | |
971 $edit = $edit_save; | |
972 $post = []; | |
973 $upload = []; | |
974 $submit_matches = $this->handleForm($post, $edit, $upload, $ajax ? NULL : $submit, $form); | |
975 $action = isset($form['action']) ? $this->getAbsoluteUrl((string) $form['action']) : $this->getUrl(); | |
976 if ($ajax) { | |
977 if (empty($submit['path'])) { | |
978 throw new \Exception('No #ajax path specified.'); | |
979 } | |
980 $action = $this->getAbsoluteUrl($submit['path']); | |
981 // Ajax callbacks verify the triggering element if necessary, so while | |
982 // we may eventually want extra code that verifies it in the | |
983 // handleForm() function, it's not currently a requirement. | |
984 $submit_matches = TRUE; | |
985 } | |
986 // We post only if we managed to handle every field in edit and the | |
987 // submit button matches. | |
988 if (!$edit && ($submit_matches || !isset($submit))) { | |
989 $post_array = $post; | |
990 if ($upload) { | |
991 foreach ($upload as $key => $file) { | |
992 if (is_array($file) && count($file)) { | |
993 // There seems to be no way via php's API to cURL to upload | |
994 // several files with the same post field name. However, Drupal | |
995 // still sees array-index syntax in a similar way. | |
996 for ($i = 0; $i < count($file); $i++) { | |
997 $postfield = str_replace('[]', '', $key) . '[' . $i . ']'; | |
998 $file_path = $this->container->get('file_system')->realpath($file[$i]); | |
999 $post[$postfield] = curl_file_create($file_path); | |
1000 } | |
1001 } | |
1002 else { | |
1003 $file = $this->container->get('file_system')->realpath($file); | |
1004 if ($file && is_file($file)) { | |
1005 $post[$key] = curl_file_create($file); | |
1006 } | |
1007 } | |
1008 } | |
1009 } | |
1010 else { | |
1011 $post = $this->serializePostValues($post) . $extra_post; | |
1012 } | |
1013 $out = $this->curlExec([CURLOPT_URL => $action, CURLOPT_POST => TRUE, CURLOPT_POSTFIELDS => $post, CURLOPT_HTTPHEADER => $headers]); | |
1014 // Ensure that any changes to variables in the other thread are picked | |
1015 // up. | |
1016 $this->refreshVariables(); | |
1017 | |
1018 // Replace original page output with new output from redirected | |
1019 // page(s). | |
1020 if ($new = $this->checkForMetaRefresh()) { | |
1021 $out = $new; | |
1022 } | |
1023 | |
1024 if ($path instanceof Url) { | |
1025 $path = $path->toString(); | |
1026 } | |
1027 $verbose = 'POST request to: ' . $path; | |
1028 $verbose .= '<hr />Ending URL: ' . $this->getUrl(); | |
1029 if ($this->dumpHeaders) { | |
1030 $verbose .= '<hr />Headers: <pre>' . Html::escape(var_export(array_map('trim', $this->headers), TRUE)) . '</pre>'; | |
1031 } | |
1032 $verbose .= '<hr />Fields: ' . highlight_string('<?php ' . var_export($post_array, TRUE), TRUE); | |
1033 $verbose .= '<hr />' . $out; | |
1034 | |
1035 $this->verbose($verbose); | |
1036 return $out; | |
1037 } | |
1038 } | |
1039 // We have not found a form which contained all fields of $edit. | |
1040 foreach ($edit as $name => $value) { | |
1041 $this->fail(SafeMarkup::format('Failed to set field @name to @value', ['@name' => $name, '@value' => $value])); | |
1042 } | |
1043 if (!$ajax && isset($submit)) { | |
1044 $this->assertTrue($submit_matches, format_string('Found the @submit button', ['@submit' => $submit])); | |
1045 } | |
1046 $this->fail(format_string('Found the requested form fields at @path', ['@path' => ($path instanceof Url) ? $path->toString() : $path])); | |
1047 } | |
1048 } | |
1049 | |
1050 /** | |
1051 * Executes an Ajax form submission. | |
1052 * | |
1053 * This executes a POST as ajax.js does. The returned JSON data is used to | |
1054 * update $this->content via drupalProcessAjaxResponse(). It also returns | |
1055 * the array of AJAX commands received. | |
1056 * | |
1057 * @param \Drupal\Core\Url|string $path | |
1058 * Location of the form containing the Ajax enabled element to test. Can be | |
1059 * either a Drupal path or an absolute path or NULL to use the current page. | |
1060 * @param $edit | |
1061 * Field data in an associative array. Changes the current input fields | |
1062 * (where possible) to the values indicated. | |
1063 * @param $triggering_element | |
1064 * The name of the form element that is responsible for triggering the Ajax | |
1065 * functionality to test. May be a string or, if the triggering element is | |
1066 * a button, an associative array where the key is the name of the button | |
1067 * and the value is the button label. i.e.) array('op' => t('Refresh')). | |
1068 * @param $ajax_path | |
1069 * (optional) Override the path set by the Ajax settings of the triggering | |
1070 * element. | |
1071 * @param $options | |
1072 * (optional) Options to be forwarded to the url generator. | |
1073 * @param $headers | |
1074 * (optional) An array containing additional HTTP request headers, each | |
1075 * formatted as "name: value". Forwarded to drupalPostForm(). | |
1076 * @param $form_html_id | |
1077 * (optional) HTML ID of the form to be submitted, use when there is more | |
1078 * than one identical form on the same page and the value of the triggering | |
1079 * element is not enough to identify the form. Note this is not the Drupal | |
1080 * ID of the form but rather the HTML ID of the form. | |
1081 * @param $ajax_settings | |
1082 * (optional) An array of Ajax settings which if specified will be used in | |
1083 * place of the Ajax settings of the triggering element. | |
1084 * | |
1085 * @return | |
1086 * An array of Ajax commands. | |
1087 * | |
1088 * @see drupalPostForm() | |
1089 * @see drupalProcessAjaxResponse() | |
1090 * @see ajax.js | |
1091 */ | |
1092 protected function drupalPostAjaxForm($path, $edit, $triggering_element, $ajax_path = NULL, array $options = [], array $headers = [], $form_html_id = NULL, $ajax_settings = NULL) { | |
1093 | |
1094 // Get the content of the initial page prior to calling drupalPostForm(), | |
1095 // since drupalPostForm() replaces $this->content. | |
1096 if (isset($path)) { | |
1097 // Avoid sending the wrapper query argument to drupalGet so we can fetch | |
1098 // the form and populate the internal WebTest values. | |
1099 $get_options = $options; | |
1100 unset($get_options['query'][MainContentViewSubscriber::WRAPPER_FORMAT]); | |
1101 $this->drupalGet($path, $get_options); | |
1102 } | |
1103 $content = $this->content; | |
1104 $drupal_settings = $this->drupalSettings; | |
1105 | |
1106 // Provide a default value for the wrapper envelope. | |
1107 $options['query'][MainContentViewSubscriber::WRAPPER_FORMAT] = | |
1108 isset($options['query'][MainContentViewSubscriber::WRAPPER_FORMAT]) ? | |
1109 $options['query'][MainContentViewSubscriber::WRAPPER_FORMAT] : | |
1110 'drupal_ajax'; | |
1111 | |
1112 // Get the Ajax settings bound to the triggering element. | |
1113 if (!isset($ajax_settings)) { | |
1114 if (is_array($triggering_element)) { | |
1115 $xpath = '//*[@name="' . key($triggering_element) . '" and @value="' . current($triggering_element) . '"]'; | |
1116 } | |
1117 else { | |
1118 $xpath = '//*[@name="' . $triggering_element . '"]'; | |
1119 } | |
1120 if (isset($form_html_id)) { | |
1121 $xpath = '//form[@id="' . $form_html_id . '"]' . $xpath; | |
1122 } | |
1123 $element = $this->xpath($xpath); | |
1124 $element_id = (string) $element[0]['id']; | |
1125 $ajax_settings = $drupal_settings['ajax'][$element_id]; | |
1126 } | |
1127 | |
1128 // Add extra information to the POST data as ajax.js does. | |
1129 $extra_post = []; | |
1130 if (isset($ajax_settings['submit'])) { | |
1131 foreach ($ajax_settings['submit'] as $key => $value) { | |
1132 $extra_post[$key] = $value; | |
1133 } | |
1134 } | |
1135 $extra_post[AjaxResponseSubscriber::AJAX_REQUEST_PARAMETER] = 1; | |
1136 $extra_post += $this->getAjaxPageStatePostData(); | |
1137 // Now serialize all the $extra_post values, and prepend it with an '&'. | |
1138 $extra_post = '&' . $this->serializePostValues($extra_post); | |
1139 | |
1140 // Unless a particular path is specified, use the one specified by the | |
1141 // Ajax settings. | |
1142 if (!isset($ajax_path)) { | |
1143 if (isset($ajax_settings['url'])) { | |
1144 // In order to allow to set for example the wrapper envelope query | |
1145 // parameter we need to get the system path again. | |
1146 $parsed_url = UrlHelper::parse($ajax_settings['url']); | |
1147 $options['query'] = $parsed_url['query'] + $options['query']; | |
1148 $options += ['fragment' => $parsed_url['fragment']]; | |
1149 | |
1150 // We know that $parsed_url['path'] is already with the base path | |
1151 // attached. | |
1152 $ajax_path = preg_replace( | |
1153 '/^' . preg_quote(base_path(), '/') . '/', | |
1154 '', | |
1155 $parsed_url['path'] | |
1156 ); | |
1157 } | |
1158 } | |
1159 | |
1160 if (empty($ajax_path)) { | |
1161 throw new \Exception('No #ajax path specified.'); | |
1162 } | |
1163 | |
1164 $ajax_path = $this->container->get('unrouted_url_assembler')->assemble('base://' . $ajax_path, $options); | |
1165 | |
1166 // Submit the POST request. | |
1167 $return = Json::decode($this->drupalPostForm(NULL, $edit, ['path' => $ajax_path, 'triggering_element' => $triggering_element], $options, $headers, $form_html_id, $extra_post)); | |
1168 if ($this->assertAjaxHeader) { | |
1169 $this->assertIdentical($this->drupalGetHeader('X-Drupal-Ajax-Token'), '1', 'Ajax response header found.'); | |
1170 } | |
1171 | |
1172 // Change the page content by applying the returned commands. | |
1173 if (!empty($ajax_settings) && !empty($return)) { | |
1174 $this->drupalProcessAjaxResponse($content, $return, $ajax_settings, $drupal_settings); | |
1175 } | |
1176 | |
1177 $verbose = 'AJAX POST request to: ' . $path; | |
1178 $verbose .= '<br />AJAX controller path: ' . $ajax_path; | |
1179 $verbose .= '<hr />Ending URL: ' . $this->getUrl(); | |
1180 $verbose .= '<hr />' . $this->content; | |
1181 | |
1182 $this->verbose($verbose); | |
1183 | |
1184 return $return; | |
1185 } | |
1186 | |
1187 /** | |
1188 * Processes an AJAX response into current content. | |
1189 * | |
1190 * This processes the AJAX response as ajax.js does. It uses the response's | |
1191 * JSON data, an array of commands, to update $this->content using equivalent | |
1192 * DOM manipulation as is used by ajax.js. | |
1193 * It does not apply custom AJAX commands though, because emulation is only | |
1194 * implemented for the AJAX commands that ship with Drupal core. | |
1195 * | |
1196 * @param string $content | |
1197 * The current HTML content. | |
1198 * @param array $ajax_response | |
1199 * An array of AJAX commands. | |
1200 * @param array $ajax_settings | |
1201 * An array of AJAX settings which will be used to process the response. | |
1202 * @param array $drupal_settings | |
1203 * An array of settings to update the value of drupalSettings for the | |
1204 * currently-loaded page. | |
1205 * | |
1206 * @see drupalPostAjaxForm() | |
1207 * @see ajax.js | |
1208 */ | |
1209 protected function drupalProcessAjaxResponse($content, array $ajax_response, array $ajax_settings, array $drupal_settings) { | |
1210 | |
1211 // ajax.js applies some defaults to the settings object, so do the same | |
1212 // for what's used by this function. | |
1213 $ajax_settings += [ | |
1214 'method' => 'replaceWith', | |
1215 ]; | |
1216 // DOM can load HTML soup. But, HTML soup can throw warnings, suppress | |
1217 // them. | |
1218 $dom = new \DOMDocument(); | |
1219 @$dom->loadHTML($content); | |
1220 // XPath allows for finding wrapper nodes better than DOM does. | |
1221 $xpath = new \DOMXPath($dom); | |
1222 foreach ($ajax_response as $command) { | |
1223 // Error messages might be not commands. | |
1224 if (!is_array($command)) { | |
1225 continue; | |
1226 } | |
1227 switch ($command['command']) { | |
1228 case 'settings': | |
1229 $drupal_settings = NestedArray::mergeDeepArray([$drupal_settings, $command['settings']], TRUE); | |
1230 break; | |
1231 | |
1232 case 'insert': | |
1233 $wrapperNode = NULL; | |
1234 // When a command doesn't specify a selector, use the | |
1235 // #ajax['wrapper'] which is always an HTML ID. | |
1236 if (!isset($command['selector'])) { | |
1237 $wrapperNode = $xpath->query('//*[@id="' . $ajax_settings['wrapper'] . '"]')->item(0); | |
1238 } | |
1239 // @todo Ajax commands can target any jQuery selector, but these are | |
1240 // hard to fully emulate with XPath. For now, just handle 'head' | |
1241 // and 'body', since these are used by the Ajax renderer. | |
1242 elseif (in_array($command['selector'], ['head', 'body'])) { | |
1243 $wrapperNode = $xpath->query('//' . $command['selector'])->item(0); | |
1244 } | |
1245 if ($wrapperNode) { | |
1246 // ajax.js adds an enclosing DIV to work around a Safari bug. | |
1247 $newDom = new \DOMDocument(); | |
1248 // DOM can load HTML soup. But, HTML soup can throw warnings, | |
1249 // suppress them. | |
1250 @$newDom->loadHTML('<div>' . $command['data'] . '</div>'); | |
1251 // Suppress warnings thrown when duplicate HTML IDs are encountered. | |
1252 // This probably means we are replacing an element with the same ID. | |
1253 $newNode = @$dom->importNode($newDom->documentElement->firstChild->firstChild, TRUE); | |
1254 $method = isset($command['method']) ? $command['method'] : $ajax_settings['method']; | |
1255 // The "method" is a jQuery DOM manipulation function. Emulate | |
1256 // each one using PHP's DOMNode API. | |
1257 switch ($method) { | |
1258 case 'replaceWith': | |
1259 $wrapperNode->parentNode->replaceChild($newNode, $wrapperNode); | |
1260 break; | |
1261 case 'append': | |
1262 $wrapperNode->appendChild($newNode); | |
1263 break; | |
1264 case 'prepend': | |
1265 // If no firstChild, insertBefore() falls back to | |
1266 // appendChild(). | |
1267 $wrapperNode->insertBefore($newNode, $wrapperNode->firstChild); | |
1268 break; | |
1269 case 'before': | |
1270 $wrapperNode->parentNode->insertBefore($newNode, $wrapperNode); | |
1271 break; | |
1272 case 'after': | |
1273 // If no nextSibling, insertBefore() falls back to | |
1274 // appendChild(). | |
1275 $wrapperNode->parentNode->insertBefore($newNode, $wrapperNode->nextSibling); | |
1276 break; | |
1277 case 'html': | |
1278 foreach ($wrapperNode->childNodes as $childNode) { | |
1279 $wrapperNode->removeChild($childNode); | |
1280 } | |
1281 $wrapperNode->appendChild($newNode); | |
1282 break; | |
1283 } | |
1284 } | |
1285 break; | |
1286 | |
1287 // @todo Add suitable implementations for these commands in order to | |
1288 // have full test coverage of what ajax.js can do. | |
1289 case 'remove': | |
1290 break; | |
1291 case 'changed': | |
1292 break; | |
1293 case 'css': | |
1294 break; | |
1295 case 'data': | |
1296 break; | |
1297 case 'restripe': | |
1298 break; | |
1299 case 'add_css': | |
1300 break; | |
1301 case 'update_build_id': | |
1302 $buildId = $xpath->query('//input[@name="form_build_id" and @value="' . $command['old'] . '"]')->item(0); | |
1303 if ($buildId) { | |
1304 $buildId->setAttribute('value', $command['new']); | |
1305 } | |
1306 break; | |
1307 } | |
1308 } | |
1309 $content = $dom->saveHTML(); | |
1310 $this->setRawContent($content); | |
1311 $this->setDrupalSettings($drupal_settings); | |
1312 } | |
1313 | |
1314 /** | |
1315 * Perform a POST HTTP request. | |
1316 * | |
1317 * @param string|\Drupal\Core\Url $path | |
1318 * Drupal path or absolute path where the request should be POSTed. | |
1319 * @param string $accept | |
1320 * The value for the "Accept" header. Usually either 'application/json' or | |
1321 * 'application/vnd.drupal-ajax'. | |
1322 * @param array $post | |
1323 * The POST data. When making a 'application/vnd.drupal-ajax' request, the | |
1324 * Ajax page state data should be included. Use getAjaxPageStatePostData() | |
1325 * for that. | |
1326 * @param array $options | |
1327 * (optional) Options to be forwarded to the url generator. The 'absolute' | |
1328 * option will automatically be enabled. | |
1329 * | |
1330 * @return | |
1331 * The content returned from the call to curl_exec(). | |
1332 * | |
1333 * @see WebTestBase::getAjaxPageStatePostData() | |
1334 * @see WebTestBase::curlExec() | |
1335 */ | |
1336 protected function drupalPost($path, $accept, array $post, $options = []) { | |
1337 return $this->curlExec([ | |
1338 CURLOPT_URL => $this->buildUrl($path, $options), | |
1339 CURLOPT_POST => TRUE, | |
1340 CURLOPT_POSTFIELDS => $this->serializePostValues($post), | |
1341 CURLOPT_HTTPHEADER => [ | |
1342 'Accept: ' . $accept, | |
1343 'Content-Type: application/x-www-form-urlencoded', | |
1344 ], | |
1345 ]); | |
1346 } | |
1347 | |
1348 /** | |
1349 * Performs a POST HTTP request with a specific format. | |
1350 * | |
1351 * @param string|\Drupal\Core\Url $path | |
1352 * Drupal path or absolute path where the request should be POSTed. | |
1353 * @param string $format | |
1354 * The request format. | |
1355 * @param array $post | |
1356 * The POST data. When making a 'application/vnd.drupal-ajax' request, the | |
1357 * Ajax page state data should be included. Use getAjaxPageStatePostData() | |
1358 * for that. | |
1359 * @param array $options | |
1360 * (optional) Options to be forwarded to the url generator. The 'absolute' | |
1361 * option will automatically be enabled. | |
1362 * | |
1363 * @return string | |
1364 * The content returned from the call to curl_exec(). | |
1365 * | |
1366 * @see WebTestBase::drupalPost | |
1367 * @see WebTestBase::getAjaxPageStatePostData() | |
1368 * @see WebTestBase::curlExec() | |
1369 */ | |
1370 protected function drupalPostWithFormat($path, $format, array $post, $options = []) { | |
1371 $options['query']['_format'] = $format; | |
1372 return $this->drupalPost($path, '', $post, $options); | |
1373 } | |
1374 | |
1375 /** | |
1376 * Get the Ajax page state from drupalSettings and prepare it for POSTing. | |
1377 * | |
1378 * @return array | |
1379 * The Ajax page state POST data. | |
1380 */ | |
1381 protected function getAjaxPageStatePostData() { | |
1382 $post = []; | |
1383 $drupal_settings = $this->drupalSettings; | |
1384 if (isset($drupal_settings['ajaxPageState']['theme'])) { | |
1385 $post['ajax_page_state[theme]'] = $drupal_settings['ajaxPageState']['theme']; | |
1386 } | |
1387 if (isset($drupal_settings['ajaxPageState']['theme_token'])) { | |
1388 $post['ajax_page_state[theme_token]'] = $drupal_settings['ajaxPageState']['theme_token']; | |
1389 } | |
1390 if (isset($drupal_settings['ajaxPageState']['libraries'])) { | |
1391 $post['ajax_page_state[libraries]'] = $drupal_settings['ajaxPageState']['libraries']; | |
1392 } | |
1393 return $post; | |
1394 } | |
1395 | |
1396 /** | |
1397 * Serialize POST HTTP request values. | |
1398 * | |
1399 * Encode according to application/x-www-form-urlencoded. Both names and | |
1400 * values needs to be urlencoded, according to | |
1401 * http://www.w3.org/TR/html4/interact/forms.html#h-17.13.4.1 | |
1402 * | |
1403 * @param array $post | |
1404 * The array of values to be POSTed. | |
1405 * | |
1406 * @return string | |
1407 * The serialized result. | |
1408 */ | |
1409 protected function serializePostValues($post = []) { | |
1410 foreach ($post as $key => $value) { | |
1411 $post[$key] = urlencode($key) . '=' . urlencode($value); | |
1412 } | |
1413 return implode('&', $post); | |
1414 } | |
1415 | |
1416 /** | |
1417 * Transforms a nested array into a flat array suitable for WebTestBase::drupalPostForm(). | |
1418 * | |
1419 * @param array $values | |
1420 * A multi-dimensional form values array to convert. | |
1421 * | |
1422 * @return array | |
1423 * The flattened $edit array suitable for WebTestBase::drupalPostForm(). | |
1424 */ | |
1425 protected function translatePostValues(array $values) { | |
1426 $edit = []; | |
1427 // The easiest and most straightforward way to translate values suitable for | |
1428 // WebTestBase::drupalPostForm() is to actually build the POST data string | |
1429 // and convert the resulting key/value pairs back into a flat array. | |
1430 $query = http_build_query($values); | |
1431 foreach (explode('&', $query) as $item) { | |
1432 list($key, $value) = explode('=', $item); | |
1433 $edit[urldecode($key)] = urldecode($value); | |
1434 } | |
1435 return $edit; | |
1436 } | |
1437 | |
1438 /** | |
1439 * Checks for meta refresh tag and if found call drupalGet() recursively. | |
1440 * | |
1441 * This function looks for the http-equiv attribute to be set to "Refresh" and | |
1442 * is case-sensitive. | |
1443 * | |
1444 * @return | |
1445 * Either the new page content or FALSE. | |
1446 */ | |
1447 protected function checkForMetaRefresh() { | |
1448 if (strpos($this->getRawContent(), '<meta ') && $this->parse() && (!isset($this->maximumMetaRefreshCount) || $this->metaRefreshCount < $this->maximumMetaRefreshCount)) { | |
1449 $refresh = $this->xpath('//meta[@http-equiv="Refresh"]'); | |
1450 if (!empty($refresh)) { | |
1451 // Parse the content attribute of the meta tag for the format: | |
1452 // "[delay]: URL=[page_to_redirect_to]". | |
1453 if (preg_match('/\d+;\s*URL=(?<url>.*)/i', $refresh[0]['content'], $match)) { | |
1454 $this->metaRefreshCount++; | |
1455 return $this->drupalGet($this->getAbsoluteUrl(Html::decodeEntities($match['url']))); | |
1456 } | |
1457 } | |
1458 } | |
1459 return FALSE; | |
1460 } | |
1461 | |
1462 /** | |
1463 * Retrieves only the headers for a Drupal path or an absolute path. | |
1464 * | |
1465 * @param $path | |
1466 * Drupal path or URL to load into internal browser | |
1467 * @param $options | |
1468 * Options to be forwarded to the url generator. | |
1469 * @param $headers | |
1470 * An array containing additional HTTP request headers, each formatted as | |
1471 * "name: value". | |
1472 * | |
1473 * @return | |
1474 * The retrieved headers, also available as $this->getRawContent() | |
1475 */ | |
1476 protected function drupalHead($path, array $options = [], array $headers = []) { | |
1477 $options['absolute'] = TRUE; | |
1478 $url = $this->buildUrl($path, $options); | |
1479 $out = $this->curlExec([CURLOPT_NOBODY => TRUE, CURLOPT_URL => $url, CURLOPT_HTTPHEADER => $headers]); | |
1480 // Ensure that any changes to variables in the other thread are picked up. | |
1481 $this->refreshVariables(); | |
1482 | |
1483 if ($this->dumpHeaders) { | |
1484 $this->verbose('GET request to: ' . $path . | |
1485 '<hr />Ending URL: ' . $this->getUrl() . | |
1486 '<hr />Headers: <pre>' . Html::escape(var_export(array_map('trim', $this->headers), TRUE)) . '</pre>'); | |
1487 } | |
1488 | |
1489 return $out; | |
1490 } | |
1491 | |
1492 /** | |
1493 * Handles form input related to drupalPostForm(). | |
1494 * | |
1495 * Ensure that the specified fields exist and attempt to create POST data in | |
1496 * the correct manner for the particular field type. | |
1497 * | |
1498 * @param $post | |
1499 * Reference to array of post values. | |
1500 * @param $edit | |
1501 * Reference to array of edit values to be checked against the form. | |
1502 * @param $submit | |
1503 * Form submit button value. | |
1504 * @param $form | |
1505 * Array of form elements. | |
1506 * | |
1507 * @return | |
1508 * Submit value matches a valid submit input in the form. | |
1509 */ | |
1510 protected function handleForm(&$post, &$edit, &$upload, $submit, $form) { | |
1511 // Retrieve the form elements. | |
1512 $elements = $form->xpath('.//input[not(@disabled)]|.//textarea[not(@disabled)]|.//select[not(@disabled)]'); | |
1513 $submit_matches = FALSE; | |
1514 foreach ($elements as $element) { | |
1515 // SimpleXML objects need string casting all the time. | |
1516 $name = (string) $element['name']; | |
1517 // This can either be the type of <input> or the name of the tag itself | |
1518 // for <select> or <textarea>. | |
1519 $type = isset($element['type']) ? (string) $element['type'] : $element->getName(); | |
1520 $value = isset($element['value']) ? (string) $element['value'] : ''; | |
1521 $done = FALSE; | |
1522 if (isset($edit[$name])) { | |
1523 switch ($type) { | |
1524 case 'text': | |
1525 case 'tel': | |
1526 case 'textarea': | |
1527 case 'url': | |
1528 case 'number': | |
1529 case 'range': | |
1530 case 'color': | |
1531 case 'hidden': | |
1532 case 'password': | |
1533 case 'email': | |
1534 case 'search': | |
1535 case 'date': | |
1536 case 'time': | |
1537 case 'datetime': | |
1538 case 'datetime-local'; | |
1539 $post[$name] = $edit[$name]; | |
1540 unset($edit[$name]); | |
1541 break; | |
1542 case 'radio': | |
1543 if ($edit[$name] == $value) { | |
1544 $post[$name] = $edit[$name]; | |
1545 unset($edit[$name]); | |
1546 } | |
1547 break; | |
1548 case 'checkbox': | |
1549 // To prevent checkbox from being checked.pass in a FALSE, | |
1550 // otherwise the checkbox will be set to its value regardless | |
1551 // of $edit. | |
1552 if ($edit[$name] === FALSE) { | |
1553 unset($edit[$name]); | |
1554 continue 2; | |
1555 } | |
1556 else { | |
1557 unset($edit[$name]); | |
1558 $post[$name] = $value; | |
1559 } | |
1560 break; | |
1561 case 'select': | |
1562 $new_value = $edit[$name]; | |
1563 $options = $this->getAllOptions($element); | |
1564 if (is_array($new_value)) { | |
1565 // Multiple select box. | |
1566 if (!empty($new_value)) { | |
1567 $index = 0; | |
1568 $key = preg_replace('/\[\]$/', '', $name); | |
1569 foreach ($options as $option) { | |
1570 $option_value = (string) $option['value']; | |
1571 if (in_array($option_value, $new_value)) { | |
1572 $post[$key . '[' . $index++ . ']'] = $option_value; | |
1573 $done = TRUE; | |
1574 unset($edit[$name]); | |
1575 } | |
1576 } | |
1577 } | |
1578 else { | |
1579 // No options selected: do not include any POST data for the | |
1580 // element. | |
1581 $done = TRUE; | |
1582 unset($edit[$name]); | |
1583 } | |
1584 } | |
1585 else { | |
1586 // Single select box. | |
1587 foreach ($options as $option) { | |
1588 if ($new_value == $option['value']) { | |
1589 $post[$name] = $new_value; | |
1590 unset($edit[$name]); | |
1591 $done = TRUE; | |
1592 break; | |
1593 } | |
1594 } | |
1595 } | |
1596 break; | |
1597 case 'file': | |
1598 $upload[$name] = $edit[$name]; | |
1599 unset($edit[$name]); | |
1600 break; | |
1601 } | |
1602 } | |
1603 if (!isset($post[$name]) && !$done) { | |
1604 switch ($type) { | |
1605 case 'textarea': | |
1606 $post[$name] = (string) $element; | |
1607 break; | |
1608 case 'select': | |
1609 $single = empty($element['multiple']); | |
1610 $first = TRUE; | |
1611 $index = 0; | |
1612 $key = preg_replace('/\[\]$/', '', $name); | |
1613 $options = $this->getAllOptions($element); | |
1614 foreach ($options as $option) { | |
1615 // For single select, we load the first option, if there is a | |
1616 // selected option that will overwrite it later. | |
1617 if ($option['selected'] || ($first && $single)) { | |
1618 $first = FALSE; | |
1619 if ($single) { | |
1620 $post[$name] = (string) $option['value']; | |
1621 } | |
1622 else { | |
1623 $post[$key . '[' . $index++ . ']'] = (string) $option['value']; | |
1624 } | |
1625 } | |
1626 } | |
1627 break; | |
1628 case 'file': | |
1629 break; | |
1630 case 'submit': | |
1631 case 'image': | |
1632 if (isset($submit) && $submit == $value) { | |
1633 $post[$name] = $value; | |
1634 $submit_matches = TRUE; | |
1635 } | |
1636 break; | |
1637 case 'radio': | |
1638 case 'checkbox': | |
1639 if (!isset($element['checked'])) { | |
1640 break; | |
1641 } | |
1642 // Deliberate no break. | |
1643 default: | |
1644 $post[$name] = $value; | |
1645 } | |
1646 } | |
1647 } | |
1648 // An empty name means the value is not sent. | |
1649 unset($post['']); | |
1650 return $submit_matches; | |
1651 } | |
1652 | |
1653 /** | |
1654 * Follows a link by complete name. | |
1655 * | |
1656 * Will click the first link found with this link text by default, or a later | |
1657 * one if an index is given. Match is case sensitive with normalized space. | |
1658 * The label is translated label. | |
1659 * | |
1660 * If the link is discovered and clicked, the test passes. Fail otherwise. | |
1661 * | |
1662 * @param string|\Drupal\Component\Render\MarkupInterface $label | |
1663 * Text between the anchor tags. | |
1664 * @param int $index | |
1665 * Link position counting from zero. | |
1666 * | |
1667 * @return string|bool | |
1668 * Page contents on success, or FALSE on failure. | |
1669 */ | |
1670 protected function clickLink($label, $index = 0) { | |
1671 return $this->clickLinkHelper($label, $index, '//a[normalize-space()=:label]'); | |
1672 } | |
1673 | |
1674 /** | |
1675 * Follows a link by partial name. | |
1676 * | |
1677 * If the link is discovered and clicked, the test passes. Fail otherwise. | |
1678 * | |
1679 * @param string|\Drupal\Component\Render\MarkupInterface $label | |
1680 * Text between the anchor tags, uses starts-with(). | |
1681 * @param int $index | |
1682 * Link position counting from zero. | |
1683 * | |
1684 * @return string|bool | |
1685 * Page contents on success, or FALSE on failure. | |
1686 * | |
1687 * @see ::clickLink() | |
1688 */ | |
1689 protected function clickLinkPartialName($label, $index = 0) { | |
1690 return $this->clickLinkHelper($label, $index, '//a[starts-with(normalize-space(), :label)]'); | |
1691 } | |
1692 | |
1693 /** | |
1694 * Provides a helper for ::clickLink() and ::clickLinkPartialName(). | |
1695 * | |
1696 * @param string|\Drupal\Component\Render\MarkupInterface $label | |
1697 * Text between the anchor tags, uses starts-with(). | |
1698 * @param int $index | |
1699 * Link position counting from zero. | |
1700 * @param string $pattern | |
1701 * A pattern to use for the XPath. | |
1702 * | |
1703 * @return bool|string | |
1704 * Page contents on success, or FALSE on failure. | |
1705 */ | |
1706 protected function clickLinkHelper($label, $index, $pattern) { | |
1707 // Cast MarkupInterface objects to string. | |
1708 $label = (string) $label; | |
1709 $url_before = $this->getUrl(); | |
1710 $urls = $this->xpath($pattern, [':label' => $label]); | |
1711 if (isset($urls[$index])) { | |
1712 $url_target = $this->getAbsoluteUrl($urls[$index]['href']); | |
1713 $this->pass(SafeMarkup::format('Clicked link %label (@url_target) from @url_before', ['%label' => $label, '@url_target' => $url_target, '@url_before' => $url_before]), 'Browser'); | |
1714 return $this->drupalGet($url_target); | |
1715 } | |
1716 $this->fail(SafeMarkup::format('Link %label does not exist on @url_before', ['%label' => $label, '@url_before' => $url_before]), 'Browser'); | |
1717 return FALSE; | |
1718 } | |
1719 | |
1720 /** | |
1721 * Takes a path and returns an absolute path. | |
1722 * | |
1723 * This method is implemented in the way that browsers work, see | |
1724 * https://url.spec.whatwg.org/#relative-state for more information about the | |
1725 * possible cases. | |
1726 * | |
1727 * @param string $path | |
1728 * A path from the internal browser content. | |
1729 * | |
1730 * @return string | |
1731 * The $path with $base_url prepended, if necessary. | |
1732 */ | |
1733 protected function getAbsoluteUrl($path) { | |
1734 global $base_url, $base_path; | |
1735 | |
1736 $parts = parse_url($path); | |
1737 | |
1738 // In case the $path has a host, it is already an absolute URL and we are | |
1739 // done. | |
1740 if (!empty($parts['host'])) { | |
1741 return $path; | |
1742 } | |
1743 | |
1744 // In case the $path contains just a query, we turn it into an absolute URL | |
1745 // with the same scheme, host and path, see | |
1746 // https://url.spec.whatwg.org/#relative-state. | |
1747 if (array_keys($parts) === ['query']) { | |
1748 $current_uri = new Uri($this->getUrl()); | |
1749 return (string) $current_uri->withQuery($parts['query']); | |
1750 } | |
1751 | |
1752 if (empty($parts['host'])) { | |
1753 // Ensure that we have a string (and no xpath object). | |
1754 $path = (string) $path; | |
1755 // Strip $base_path, if existent. | |
1756 $length = strlen($base_path); | |
1757 if (substr($path, 0, $length) === $base_path) { | |
1758 $path = substr($path, $length); | |
1759 } | |
1760 // Ensure that we have an absolute path. | |
1761 if (empty($path) || $path[0] !== '/') { | |
1762 $path = '/' . $path; | |
1763 } | |
1764 // Finally, prepend the $base_url. | |
1765 $path = $base_url . $path; | |
1766 } | |
1767 return $path; | |
1768 } | |
1769 | |
1770 /** | |
1771 * Gets the HTTP response headers of the requested page. | |
1772 * | |
1773 * Normally we are only interested in the headers returned by the last | |
1774 * request. However, if a page is redirected or HTTP authentication is in use, | |
1775 * multiple requests will be required to retrieve the page. Headers from all | |
1776 * requests may be requested by passing TRUE to this function. | |
1777 * | |
1778 * @param $all_requests | |
1779 * Boolean value specifying whether to return headers from all requests | |
1780 * instead of just the last request. Defaults to FALSE. | |
1781 * | |
1782 * @return | |
1783 * A name/value array if headers from only the last request are requested. | |
1784 * If headers from all requests are requested, an array of name/value | |
1785 * arrays, one for each request. | |
1786 * | |
1787 * The pseudonym ":status" is used for the HTTP status line. | |
1788 * | |
1789 * Values for duplicate headers are stored as a single comma-separated list. | |
1790 */ | |
1791 protected function drupalGetHeaders($all_requests = FALSE) { | |
1792 $request = 0; | |
1793 $headers = [$request => []]; | |
1794 foreach ($this->headers as $header) { | |
1795 $header = trim($header); | |
1796 if ($header === '') { | |
1797 $request++; | |
1798 } | |
1799 else { | |
1800 if (strpos($header, 'HTTP/') === 0) { | |
1801 $name = ':status'; | |
1802 $value = $header; | |
1803 } | |
1804 else { | |
1805 list($name, $value) = explode(':', $header, 2); | |
1806 $name = strtolower($name); | |
1807 } | |
1808 if (isset($headers[$request][$name])) { | |
1809 $headers[$request][$name] .= ',' . trim($value); | |
1810 } | |
1811 else { | |
1812 $headers[$request][$name] = trim($value); | |
1813 } | |
1814 } | |
1815 } | |
1816 if (!$all_requests) { | |
1817 $headers = array_pop($headers); | |
1818 } | |
1819 return $headers; | |
1820 } | |
1821 | |
1822 /** | |
1823 * Gets the value of an HTTP response header. | |
1824 * | |
1825 * If multiple requests were required to retrieve the page, only the headers | |
1826 * from the last request will be checked by default. However, if TRUE is | |
1827 * passed as the second argument, all requests will be processed from last to | |
1828 * first until the header is found. | |
1829 * | |
1830 * @param $name | |
1831 * The name of the header to retrieve. Names are case-insensitive (see RFC | |
1832 * 2616 section 4.2). | |
1833 * @param $all_requests | |
1834 * Boolean value specifying whether to check all requests if the header is | |
1835 * not found in the last request. Defaults to FALSE. | |
1836 * | |
1837 * @return | |
1838 * The HTTP header value or FALSE if not found. | |
1839 */ | |
1840 protected function drupalGetHeader($name, $all_requests = FALSE) { | |
1841 $name = strtolower($name); | |
1842 $header = FALSE; | |
1843 if ($all_requests) { | |
1844 foreach (array_reverse($this->drupalGetHeaders(TRUE)) as $headers) { | |
1845 if (isset($headers[$name])) { | |
1846 $header = $headers[$name]; | |
1847 break; | |
1848 } | |
1849 } | |
1850 } | |
1851 else { | |
1852 $headers = $this->drupalGetHeaders(); | |
1853 if (isset($headers[$name])) { | |
1854 $header = $headers[$name]; | |
1855 } | |
1856 } | |
1857 return $header; | |
1858 } | |
1859 | |
1860 /** | |
1861 * Check if a HTTP response header exists and has the expected value. | |
1862 * | |
1863 * @param string $header | |
1864 * The header key, example: Content-Type | |
1865 * @param string $value | |
1866 * The header value. | |
1867 * @param string $message | |
1868 * (optional) A message to display with the assertion. | |
1869 * @param string $group | |
1870 * (optional) The group this message is in, which is displayed in a column | |
1871 * in test output. Use 'Debug' to indicate this is debugging output. Do not | |
1872 * translate this string. Defaults to 'Other'; most tests do not override | |
1873 * this default. | |
1874 * | |
1875 * @return bool | |
1876 * TRUE if the assertion succeeded, FALSE otherwise. | |
1877 */ | |
1878 protected function assertHeader($header, $value, $message = '', $group = 'Browser') { | |
1879 $header_value = $this->drupalGetHeader($header); | |
1880 return $this->assertTrue($header_value == $value, $message ? $message : 'HTTP response header ' . $header . ' with value ' . $value . ' found, actual value: ' . $header_value, $group); | |
1881 } | |
1882 | |
1883 /** | |
1884 * Passes if the internal browser's URL matches the given path. | |
1885 * | |
1886 * @param \Drupal\Core\Url|string $path | |
1887 * The expected system path or URL. | |
1888 * @param $options | |
1889 * (optional) Any additional options to pass for $path to the url generator. | |
1890 * @param $message | |
1891 * (optional) A message to display with the assertion. Do not translate | |
1892 * messages: use \Drupal\Component\Utility\SafeMarkup::format() to embed | |
1893 * variables in the message text, not t(). If left blank, a default message | |
1894 * will be displayed. | |
1895 * @param $group | |
1896 * (optional) The group this message is in, which is displayed in a column | |
1897 * in test output. Use 'Debug' to indicate this is debugging output. Do not | |
1898 * translate this string. Defaults to 'Other'; most tests do not override | |
1899 * this default. | |
1900 * | |
1901 * @return | |
1902 * TRUE on pass, FALSE on fail. | |
1903 */ | |
1904 protected function assertUrl($path, array $options = [], $message = '', $group = 'Other') { | |
1905 if ($path instanceof Url) { | |
1906 $url_obj = $path; | |
1907 } | |
1908 elseif (UrlHelper::isExternal($path)) { | |
1909 $url_obj = Url::fromUri($path, $options); | |
1910 } | |
1911 else { | |
1912 $uri = $path === '<front>' ? 'base:/' : 'base:/' . $path; | |
1913 // This is needed for language prefixing. | |
1914 $options['path_processing'] = TRUE; | |
1915 $url_obj = Url::fromUri($uri, $options); | |
1916 } | |
1917 $url = $url_obj->setAbsolute()->toString(); | |
1918 if (!$message) { | |
1919 $message = SafeMarkup::format('Expected @url matches current URL (@current_url).', [ | |
1920 '@url' => var_export($url, TRUE), | |
1921 '@current_url' => $this->getUrl(), | |
1922 ]); | |
1923 } | |
1924 // Paths in query strings can be encoded or decoded with no functional | |
1925 // difference, decode them for comparison purposes. | |
1926 $actual_url = urldecode($this->getUrl()); | |
1927 $expected_url = urldecode($url); | |
1928 return $this->assertEqual($actual_url, $expected_url, $message, $group); | |
1929 } | |
1930 | |
1931 /** | |
1932 * Asserts the page responds with the specified response code. | |
1933 * | |
1934 * @param $code | |
1935 * Response code. For example 200 is a successful page request. For a list | |
1936 * of all codes see http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html. | |
1937 * @param $message | |
1938 * (optional) A message to display with the assertion. Do not translate | |
1939 * messages: use \Drupal\Component\Utility\SafeMarkup::format() to embed | |
1940 * variables in the message text, not t(). If left blank, a default message | |
1941 * will be displayed. | |
1942 * @param $group | |
1943 * (optional) The group this message is in, which is displayed in a column | |
1944 * in test output. Use 'Debug' to indicate this is debugging output. Do not | |
1945 * translate this string. Defaults to 'Browser'; most tests do not override | |
1946 * this default. | |
1947 * | |
1948 * @return | |
1949 * Assertion result. | |
1950 */ | |
1951 protected function assertResponse($code, $message = '', $group = 'Browser') { | |
1952 $curl_code = curl_getinfo($this->curlHandle, CURLINFO_HTTP_CODE); | |
1953 $match = is_array($code) ? in_array($curl_code, $code) : $curl_code == $code; | |
1954 return $this->assertTrue($match, $message ? $message : SafeMarkup::format('HTTP response expected @code, actual @curl_code', ['@code' => $code, '@curl_code' => $curl_code]), $group); | |
1955 } | |
1956 | |
1957 /** | |
1958 * Asserts the page did not return the specified response code. | |
1959 * | |
1960 * @param $code | |
1961 * Response code. For example 200 is a successful page request. For a list | |
1962 * of all codes see http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html. | |
1963 * @param $message | |
1964 * (optional) A message to display with the assertion. Do not translate | |
1965 * messages: use \Drupal\Component\Utility\SafeMarkup::format() to embed | |
1966 * variables in the message text, not t(). If left blank, a default message | |
1967 * will be displayed. | |
1968 * @param $group | |
1969 * (optional) The group this message is in, which is displayed in a column | |
1970 * in test output. Use 'Debug' to indicate this is debugging output. Do not | |
1971 * translate this string. Defaults to 'Browser'; most tests do not override | |
1972 * this default. | |
1973 * | |
1974 * @return | |
1975 * Assertion result. | |
1976 */ | |
1977 protected function assertNoResponse($code, $message = '', $group = 'Browser') { | |
1978 $curl_code = curl_getinfo($this->curlHandle, CURLINFO_HTTP_CODE); | |
1979 $match = is_array($code) ? in_array($curl_code, $code) : $curl_code == $code; | |
1980 return $this->assertFalse($match, $message ? $message : SafeMarkup::format('HTTP response not expected @code, actual @curl_code', ['@code' => $code, '@curl_code' => $curl_code]), $group); | |
1981 } | |
1982 | |
1983 /** | |
1984 * Builds an a absolute URL from a system path or a URL object. | |
1985 * | |
1986 * @param string|\Drupal\Core\Url $path | |
1987 * A system path or a URL. | |
1988 * @param array $options | |
1989 * Options to be passed to Url::fromUri(). | |
1990 * | |
1991 * @return string | |
1992 * An absolute URL string. | |
1993 */ | |
1994 protected function buildUrl($path, array $options = []) { | |
1995 if ($path instanceof Url) { | |
1996 $url_options = $path->getOptions(); | |
1997 $options = $url_options + $options; | |
1998 $path->setOptions($options); | |
1999 return $path->setAbsolute()->toString(TRUE)->getGeneratedUrl(); | |
2000 } | |
2001 // The URL generator service is not necessarily available yet; e.g., in | |
2002 // interactive installer tests. | |
2003 elseif ($this->container->has('url_generator')) { | |
2004 $force_internal = isset($options['external']) && $options['external'] == FALSE; | |
2005 if (!$force_internal && UrlHelper::isExternal($path)) { | |
2006 return Url::fromUri($path, $options)->toString(); | |
2007 } | |
2008 else { | |
2009 $uri = $path === '<front>' ? 'base:/' : 'base:/' . $path; | |
2010 // Path processing is needed for language prefixing. Skip it when a | |
2011 // path that may look like an external URL is being used as internal. | |
2012 $options['path_processing'] = !$force_internal; | |
2013 return Url::fromUri($uri, $options) | |
2014 ->setAbsolute() | |
2015 ->toString(); | |
2016 } | |
2017 } | |
2018 else { | |
2019 return $this->getAbsoluteUrl($path); | |
2020 } | |
2021 } | |
2022 | |
2023 /** | |
2024 * Asserts whether an expected cache tag was present in the last response. | |
2025 * | |
2026 * @param string $expected_cache_tag | |
2027 * The expected cache tag. | |
2028 */ | |
2029 protected function assertCacheTag($expected_cache_tag) { | |
2030 $cache_tags = explode(' ', $this->drupalGetHeader('X-Drupal-Cache-Tags')); | |
2031 $this->assertTrue(in_array($expected_cache_tag, $cache_tags), "'" . $expected_cache_tag . "' is present in the X-Drupal-Cache-Tags header."); | |
2032 } | |
2033 | |
2034 /** | |
2035 * Asserts whether an expected cache tag was absent in the last response. | |
2036 * | |
2037 * @param string $cache_tag | |
2038 * The cache tag to check. | |
2039 */ | |
2040 protected function assertNoCacheTag($cache_tag) { | |
2041 $cache_tags = explode(' ', $this->drupalGetHeader('X-Drupal-Cache-Tags')); | |
2042 $this->assertFalse(in_array($cache_tag, $cache_tags), "'" . $cache_tag . "' is absent in the X-Drupal-Cache-Tags header."); | |
2043 } | |
2044 | |
2045 /** | |
2046 * Enables/disables the cacheability headers. | |
2047 * | |
2048 * Sets the http.response.debug_cacheability_headers container parameter. | |
2049 * | |
2050 * @param bool $value | |
2051 * (optional) Whether the debugging cacheability headers should be sent. | |
2052 */ | |
2053 protected function setHttpResponseDebugCacheabilityHeaders($value = TRUE) { | |
2054 $this->setContainerParameter('http.response.debug_cacheability_headers', $value); | |
2055 $this->rebuildContainer(); | |
2056 $this->resetAll(); | |
2057 } | |
2058 | |
2059 } |