vendor/symfony/browser-kit/AbstractBrowser.php line 141

  1. <?php
  2. /*
  3.  * This file is part of the Symfony package.
  4.  *
  5.  * (c) Fabien Potencier <fabien@symfony.com>
  6.  *
  7.  * For the full copyright and license information, please view the LICENSE
  8.  * file that was distributed with this source code.
  9.  */
  10. namespace Symfony\Component\BrowserKit;
  11. use Symfony\Component\BrowserKit\Exception\BadMethodCallException;
  12. use Symfony\Component\DomCrawler\Crawler;
  13. use Symfony\Component\DomCrawler\Form;
  14. use Symfony\Component\DomCrawler\Link;
  15. use Symfony\Component\Process\PhpProcess;
  16. /**
  17.  * Simulates a browser.
  18.  *
  19.  * To make the actual request, you need to implement the doRequest() method.
  20.  *
  21.  * If you want to be able to run requests in their own process (insulated flag),
  22.  * you need to also implement the getScript() method.
  23.  *
  24.  * @author Fabien Potencier <fabien@symfony.com>
  25.  */
  26. abstract class AbstractBrowser
  27. {
  28.     protected $history;
  29.     protected $cookieJar;
  30.     protected $server = [];
  31.     protected $internalRequest;
  32.     protected $request;
  33.     protected $internalResponse;
  34.     protected $response;
  35.     protected $crawler;
  36.     protected $insulated false;
  37.     protected $redirect;
  38.     protected $followRedirects true;
  39.     protected $followMetaRefresh false;
  40.     private int $maxRedirects = -1;
  41.     private int $redirectCount 0;
  42.     private array $redirects = [];
  43.     private bool $isMainRequest true;
  44.     /**
  45.      * @param array $server The server parameters (equivalent of $_SERVER)
  46.      */
  47.     public function __construct(array $server = [], History $history nullCookieJar $cookieJar null)
  48.     {
  49.         $this->setServerParameters($server);
  50.         $this->history $history ?? new History();
  51.         $this->cookieJar $cookieJar ?? new CookieJar();
  52.     }
  53.     /**
  54.      * Sets whether to automatically follow redirects or not.
  55.      */
  56.     public function followRedirects(bool $followRedirects true)
  57.     {
  58.         $this->followRedirects $followRedirects;
  59.     }
  60.     /**
  61.      * Sets whether to automatically follow meta refresh redirects or not.
  62.      */
  63.     public function followMetaRefresh(bool $followMetaRefresh true)
  64.     {
  65.         $this->followMetaRefresh $followMetaRefresh;
  66.     }
  67.     /**
  68.      * Returns whether client automatically follows redirects or not.
  69.      */
  70.     public function isFollowingRedirects(): bool
  71.     {
  72.         return $this->followRedirects;
  73.     }
  74.     /**
  75.      * Sets the maximum number of redirects that crawler can follow.
  76.      */
  77.     public function setMaxRedirects(int $maxRedirects)
  78.     {
  79.         $this->maxRedirects $maxRedirects ? -$maxRedirects;
  80.         $this->followRedirects = -!== $this->maxRedirects;
  81.     }
  82.     /**
  83.      * Returns the maximum number of redirects that crawler can follow.
  84.      */
  85.     public function getMaxRedirects(): int
  86.     {
  87.         return $this->maxRedirects;
  88.     }
  89.     /**
  90.      * Sets the insulated flag.
  91.      *
  92.      * @throws \RuntimeException When Symfony Process Component is not installed
  93.      */
  94.     public function insulate(bool $insulated true)
  95.     {
  96.         if ($insulated && !class_exists(\Symfony\Component\Process\Process::class)) {
  97.             throw new \LogicException('Unable to isolate requests as the Symfony Process Component is not installed.');
  98.         }
  99.         $this->insulated $insulated;
  100.     }
  101.     /**
  102.      * Sets server parameters.
  103.      */
  104.     public function setServerParameters(array $server)
  105.     {
  106.         $this->server array_merge([
  107.             'HTTP_USER_AGENT' => 'Symfony BrowserKit',
  108.         ], $server);
  109.     }
  110.     /**
  111.      * Sets single server parameter.
  112.      */
  113.     public function setServerParameter(string $keystring $value)
  114.     {
  115.         $this->server[$key] = $value;
  116.     }
  117.     /**
  118.      * Gets single server parameter for specified key.
  119.      */
  120.     public function getServerParameter(string $keymixed $default ''): mixed
  121.     {
  122.         return $this->server[$key] ?? $default;
  123.     }
  124.     public function xmlHttpRequest(string $methodstring $uri, array $parameters = [], array $files = [], array $server = [], string $content nullbool $changeHistory true): Crawler
  125.     {
  126.         $this->setServerParameter('HTTP_X_REQUESTED_WITH''XMLHttpRequest');
  127.         try {
  128.             return $this->request($method$uri$parameters$files$server$content$changeHistory);
  129.         } finally {
  130.             unset($this->server['HTTP_X_REQUESTED_WITH']);
  131.         }
  132.     }
  133.     /**
  134.      * Converts the request parameters into a JSON string and uses it as request content.
  135.      */
  136.     public function jsonRequest(string $methodstring $uri, array $parameters = [], array $server = [], bool $changeHistory true): Crawler
  137.     {
  138.         $content json_encode($parameters);
  139.         $this->setServerParameter('CONTENT_TYPE''application/json');
  140.         $this->setServerParameter('HTTP_ACCEPT''application/json');
  141.         try {
  142.             return $this->request($method$uri, [], [], $server$content$changeHistory);
  143.         } finally {
  144.             unset($this->server['CONTENT_TYPE']);
  145.             unset($this->server['HTTP_ACCEPT']);
  146.         }
  147.     }
  148.     /**
  149.      * Returns the History instance.
  150.      */
  151.     public function getHistory(): History
  152.     {
  153.         return $this->history;
  154.     }
  155.     /**
  156.      * Returns the CookieJar instance.
  157.      */
  158.     public function getCookieJar(): CookieJar
  159.     {
  160.         return $this->cookieJar;
  161.     }
  162.     /**
  163.      * Returns the current Crawler instance.
  164.      */
  165.     public function getCrawler(): Crawler
  166.     {
  167.         if (null === $this->crawler) {
  168.             throw new BadMethodCallException(sprintf('The "request()" method must be called before "%s()".'__METHOD__));
  169.         }
  170.         return $this->crawler;
  171.     }
  172.     /**
  173.      * Returns the current BrowserKit Response instance.
  174.      */
  175.     public function getInternalResponse(): Response
  176.     {
  177.         if (null === $this->internalResponse) {
  178.             throw new BadMethodCallException(sprintf('The "request()" method must be called before "%s()".'__METHOD__));
  179.         }
  180.         return $this->internalResponse;
  181.     }
  182.     /**
  183.      * Returns the current origin response instance.
  184.      *
  185.      * The origin response is the response instance that is returned
  186.      * by the code that handles requests.
  187.      *
  188.      * @see doRequest()
  189.      */
  190.     public function getResponse(): object
  191.     {
  192.         if (null === $this->response) {
  193.             throw new BadMethodCallException(sprintf('The "request()" method must be called before "%s()".'__METHOD__));
  194.         }
  195.         return $this->response;
  196.     }
  197.     /**
  198.      * Returns the current BrowserKit Request instance.
  199.      */
  200.     public function getInternalRequest(): Request
  201.     {
  202.         if (null === $this->internalRequest) {
  203.             throw new BadMethodCallException(sprintf('The "request()" method must be called before "%s()".'__METHOD__));
  204.         }
  205.         return $this->internalRequest;
  206.     }
  207.     /**
  208.      * Returns the current origin Request instance.
  209.      *
  210.      * The origin request is the request instance that is sent
  211.      * to the code that handles requests.
  212.      *
  213.      * @see doRequest()
  214.      */
  215.     public function getRequest(): object
  216.     {
  217.         if (null === $this->request) {
  218.             throw new BadMethodCallException(sprintf('The "request()" method must be called before "%s()".'__METHOD__));
  219.         }
  220.         return $this->request;
  221.     }
  222.     /**
  223.      * Clicks on a given link.
  224.      */
  225.     public function click(Link $link): Crawler
  226.     {
  227.         if ($link instanceof Form) {
  228.             return $this->submit($link);
  229.         }
  230.         return $this->request($link->getMethod(), $link->getUri());
  231.     }
  232.     /**
  233.      * Clicks the first link (or clickable image) that contains the given text.
  234.      *
  235.      * @param string $linkText The text of the link or the alt attribute of the clickable image
  236.      */
  237.     public function clickLink(string $linkText): Crawler
  238.     {
  239.         if (null === $this->crawler) {
  240.             throw new BadMethodCallException(sprintf('The "request()" method must be called before "%s()".'__METHOD__));
  241.         }
  242.         return $this->click($this->crawler->selectLink($linkText)->link());
  243.     }
  244.     /**
  245.      * Submits a form.
  246.      *
  247.      * @param array $values           An array of form field values
  248.      * @param array $serverParameters An array of server parameters
  249.      */
  250.     public function submit(Form $form, array $values = [], array $serverParameters = []): Crawler
  251.     {
  252.         $form->setValues($values);
  253.         return $this->request($form->getMethod(), $form->getUri(), $form->getPhpValues(), $form->getPhpFiles(), $serverParameters);
  254.     }
  255.     /**
  256.      * Finds the first form that contains a button with the given content and
  257.      * uses it to submit the given form field values.
  258.      *
  259.      * @param string $button           The text content, id, value or name of the form <button> or <input type="submit">
  260.      * @param array  $fieldValues      Use this syntax: ['my_form[name]' => '...', 'my_form[email]' => '...']
  261.      * @param string $method           The HTTP method used to submit the form
  262.      * @param array  $serverParameters These values override the ones stored in $_SERVER (HTTP headers must include an HTTP_ prefix as PHP does)
  263.      */
  264.     public function submitForm(string $button, array $fieldValues = [], string $method 'POST', array $serverParameters = []): Crawler
  265.     {
  266.         if (null === $this->crawler) {
  267.             throw new BadMethodCallException(sprintf('The "request()" method must be called before "%s()".'__METHOD__));
  268.         }
  269.         $buttonNode $this->crawler->selectButton($button);
  270.         $form $buttonNode->form($fieldValues$method);
  271.         return $this->submit($form, [], $serverParameters);
  272.     }
  273.     /**
  274.      * Calls a URI.
  275.      *
  276.      * @param string $method        The request method
  277.      * @param string $uri           The URI to fetch
  278.      * @param array  $parameters    The Request parameters
  279.      * @param array  $files         The files
  280.      * @param array  $server        The server parameters (HTTP headers are referenced with an HTTP_ prefix as PHP does)
  281.      * @param string $content       The raw body data
  282.      * @param bool   $changeHistory Whether to update the history or not (only used internally for back(), forward(), and reload())
  283.      */
  284.     public function request(string $methodstring $uri, array $parameters = [], array $files = [], array $server = [], string $content nullbool $changeHistory true): Crawler
  285.     {
  286.         if ($this->isMainRequest) {
  287.             $this->redirectCount 0;
  288.         } else {
  289.             ++$this->redirectCount;
  290.         }
  291.         $originalUri $uri;
  292.         $uri $this->getAbsoluteUri($uri);
  293.         $server array_merge($this->server$server);
  294.         if (!empty($server['HTTP_HOST']) && null === parse_url($originalUri\PHP_URL_HOST)) {
  295.             $uri preg_replace('{^(https?\://)'.preg_quote($this->extractHost($uri)).'}''${1}'.$server['HTTP_HOST'], $uri);
  296.         }
  297.         if (isset($server['HTTPS']) && null === parse_url($originalUri\PHP_URL_SCHEME)) {
  298.             $uri preg_replace('{^'.parse_url($uri\PHP_URL_SCHEME).'}'$server['HTTPS'] ? 'https' 'http'$uri);
  299.         }
  300.         if (!isset($server['HTTP_REFERER']) && !$this->history->isEmpty()) {
  301.             $server['HTTP_REFERER'] = $this->history->current()->getUri();
  302.         }
  303.         if (empty($server['HTTP_HOST'])) {
  304.             $server['HTTP_HOST'] = $this->extractHost($uri);
  305.         }
  306.         $server['HTTPS'] = 'https' === parse_url($uri\PHP_URL_SCHEME);
  307.         $this->internalRequest = new Request($uri$method$parameters$files$this->cookieJar->allValues($uri), $server$content);
  308.         $this->request $this->filterRequest($this->internalRequest);
  309.         if (true === $changeHistory) {
  310.             $this->history->add($this->internalRequest);
  311.         }
  312.         if ($this->insulated) {
  313.             $this->response $this->doRequestInProcess($this->request);
  314.         } else {
  315.             $this->response $this->doRequest($this->request);
  316.         }
  317.         $this->internalResponse $this->filterResponse($this->response);
  318.         $this->cookieJar->updateFromResponse($this->internalResponse$uri);
  319.         $status $this->internalResponse->getStatusCode();
  320.         if ($status >= 300 && $status 400) {
  321.             $this->redirect $this->internalResponse->getHeader('Location');
  322.         } else {
  323.             $this->redirect null;
  324.         }
  325.         if ($this->followRedirects && $this->redirect) {
  326.             $this->redirects[serialize($this->history->current())] = true;
  327.             return $this->crawler $this->followRedirect();
  328.         }
  329.         $this->crawler $this->createCrawlerFromContent($this->internalRequest->getUri(), $this->internalResponse->getContent(), $this->internalResponse->getHeader('Content-Type') ?? '');
  330.         // Check for meta refresh redirect
  331.         if ($this->followMetaRefresh && null !== $redirect $this->getMetaRefreshUrl()) {
  332.             $this->redirect $redirect;
  333.             $this->redirects[serialize($this->history->current())] = true;
  334.             $this->crawler $this->followRedirect();
  335.         }
  336.         return $this->crawler;
  337.     }
  338.     /**
  339.      * Makes a request in another process.
  340.      *
  341.      * @return object
  342.      *
  343.      * @throws \RuntimeException When processing returns exit code
  344.      */
  345.     protected function doRequestInProcess(object $request)
  346.     {
  347.         $deprecationsFile tempnam(sys_get_temp_dir(), 'deprec');
  348.         putenv('SYMFONY_DEPRECATIONS_SERIALIZE='.$deprecationsFile);
  349.         $_ENV['SYMFONY_DEPRECATIONS_SERIALIZE'] = $deprecationsFile;
  350.         $process = new PhpProcess($this->getScript($request), nullnull);
  351.         $process->run();
  352.         if (file_exists($deprecationsFile)) {
  353.             $deprecations file_get_contents($deprecationsFile);
  354.             unlink($deprecationsFile);
  355.             foreach ($deprecations unserialize($deprecations) : [] as $deprecation) {
  356.                 if ($deprecation[0]) {
  357.                     // unsilenced on purpose
  358.                     trigger_error($deprecation[1], \E_USER_DEPRECATED);
  359.                 } else {
  360.                     @trigger_error($deprecation[1], \E_USER_DEPRECATED);
  361.                 }
  362.             }
  363.         }
  364.         if (!$process->isSuccessful() || !preg_match('/^O\:\d+\:/'$process->getOutput())) {
  365.             throw new \RuntimeException(sprintf('OUTPUT: %s ERROR OUTPUT: %s.'$process->getOutput(), $process->getErrorOutput()));
  366.         }
  367.         return unserialize($process->getOutput());
  368.     }
  369.     /**
  370.      * Makes a request.
  371.      *
  372.      * @return object
  373.      */
  374.     abstract protected function doRequest(object $request);
  375.     /**
  376.      * Returns the script to execute when the request must be insulated.
  377.      *
  378.      * @param object $request An origin request instance
  379.      *
  380.      * @throws \LogicException When this abstract class is not implemented
  381.      */
  382.     protected function getScript(object $request)
  383.     {
  384.         throw new \LogicException('To insulate requests, you need to override the getScript() method.');
  385.     }
  386.     /**
  387.      * Filters the BrowserKit request to the origin one.
  388.      *
  389.      * @return object
  390.      */
  391.     protected function filterRequest(Request $request)
  392.     {
  393.         return $request;
  394.     }
  395.     /**
  396.      * Filters the origin response to the BrowserKit one.
  397.      *
  398.      * @return Response
  399.      */
  400.     protected function filterResponse(object $response)
  401.     {
  402.         return $response;
  403.     }
  404.     /**
  405.      * Creates a crawler.
  406.      *
  407.      * This method returns null if the DomCrawler component is not available.
  408.      */
  409.     protected function createCrawlerFromContent(string $uristring $contentstring $type): ?Crawler
  410.     {
  411.         if (!class_exists(Crawler::class)) {
  412.             return null;
  413.         }
  414.         $crawler = new Crawler(null$uri);
  415.         $crawler->addContent($content$type);
  416.         return $crawler;
  417.     }
  418.     /**
  419.      * Goes back in the browser history.
  420.      */
  421.     public function back(): Crawler
  422.     {
  423.         do {
  424.             $request $this->history->back();
  425.         } while (\array_key_exists(serialize($request), $this->redirects));
  426.         return $this->requestFromRequest($requestfalse);
  427.     }
  428.     /**
  429.      * Goes forward in the browser history.
  430.      */
  431.     public function forward(): Crawler
  432.     {
  433.         do {
  434.             $request $this->history->forward();
  435.         } while (\array_key_exists(serialize($request), $this->redirects));
  436.         return $this->requestFromRequest($requestfalse);
  437.     }
  438.     /**
  439.      * Reloads the current browser.
  440.      */
  441.     public function reload(): Crawler
  442.     {
  443.         return $this->requestFromRequest($this->history->current(), false);
  444.     }
  445.     /**
  446.      * Follow redirects?
  447.      *
  448.      * @throws \LogicException If request was not a redirect
  449.      */
  450.     public function followRedirect(): Crawler
  451.     {
  452.         if (empty($this->redirect)) {
  453.             throw new \LogicException('The request was not redirected.');
  454.         }
  455.         if (-!== $this->maxRedirects) {
  456.             if ($this->redirectCount $this->maxRedirects) {
  457.                 $this->redirectCount 0;
  458.                 throw new \LogicException(sprintf('The maximum number (%d) of redirections was reached.'$this->maxRedirects));
  459.             }
  460.         }
  461.         $request $this->internalRequest;
  462.         if (\in_array($this->internalResponse->getStatusCode(), [301302303])) {
  463.             $method 'GET';
  464.             $files = [];
  465.             $content null;
  466.         } else {
  467.             $method $request->getMethod();
  468.             $files $request->getFiles();
  469.             $content $request->getContent();
  470.         }
  471.         if ('GET' === strtoupper($method)) {
  472.             // Don't forward parameters for GET request as it should reach the redirection URI
  473.             $parameters = [];
  474.         } else {
  475.             $parameters $request->getParameters();
  476.         }
  477.         $server $request->getServer();
  478.         $server $this->updateServerFromUri($server$this->redirect);
  479.         $this->isMainRequest false;
  480.         $response $this->request($method$this->redirect$parameters$files$server$content);
  481.         $this->isMainRequest true;
  482.         return $response;
  483.     }
  484.     /**
  485.      * @see https://dev.w3.org/html5/spec-preview/the-meta-element.html#attr-meta-http-equiv-refresh
  486.      */
  487.     private function getMetaRefreshUrl(): ?string
  488.     {
  489.         $metaRefresh $this->getCrawler()->filter('head meta[http-equiv="refresh"]');
  490.         foreach ($metaRefresh->extract(['content']) as $content) {
  491.             if (preg_match('/^\s*0\s*;\s*URL\s*=\s*(?|\'([^\']++)|"([^"]++)|([^\'"].*))/i'$content$m)) {
  492.                 return str_replace("\t\r\n"''rtrim($m[1]));
  493.             }
  494.         }
  495.         return null;
  496.     }
  497.     /**
  498.      * Restarts the client.
  499.      *
  500.      * It flushes history and all cookies.
  501.      */
  502.     public function restart()
  503.     {
  504.         $this->cookieJar->clear();
  505.         $this->history->clear();
  506.     }
  507.     /**
  508.      * Takes a URI and converts it to absolute if it is not already absolute.
  509.      */
  510.     protected function getAbsoluteUri(string $uri): string
  511.     {
  512.         // already absolute?
  513.         if (str_starts_with($uri'http://') || str_starts_with($uri'https://')) {
  514.             return $uri;
  515.         }
  516.         if (!$this->history->isEmpty()) {
  517.             $currentUri $this->history->current()->getUri();
  518.         } else {
  519.             $currentUri sprintf('http%s://%s/',
  520.                 isset($this->server['HTTPS']) ? 's' '',
  521.                 $this->server['HTTP_HOST'] ?? 'localhost'
  522.             );
  523.         }
  524.         // protocol relative URL
  525.         if ('' !== trim($uri'/') && str_starts_with($uri'//')) {
  526.             return parse_url($currentUri\PHP_URL_SCHEME).':'.$uri;
  527.         }
  528.         // anchor or query string parameters?
  529.         if (!$uri || '#' === $uri[0] || '?' === $uri[0]) {
  530.             return preg_replace('/[#?].*?$/'''$currentUri).$uri;
  531.         }
  532.         if ('/' !== $uri[0]) {
  533.             $path parse_url($currentUri\PHP_URL_PATH);
  534.             if (!str_ends_with($path'/')) {
  535.                 $path substr($path0strrpos($path'/') + 1);
  536.             }
  537.             $uri $path.$uri;
  538.         }
  539.         return preg_replace('#^(.*?//[^/]+)\/.*$#''$1'$currentUri).$uri;
  540.     }
  541.     /**
  542.      * Makes a request from a Request object directly.
  543.      *
  544.      * @param bool $changeHistory Whether to update the history or not (only used internally for back(), forward(), and reload())
  545.      */
  546.     protected function requestFromRequest(Request $requestbool $changeHistory true): Crawler
  547.     {
  548.         return $this->request($request->getMethod(), $request->getUri(), $request->getParameters(), $request->getFiles(), $request->getServer(), $request->getContent(), $changeHistory);
  549.     }
  550.     private function updateServerFromUri(array $serverstring $uri): array
  551.     {
  552.         $server['HTTP_HOST'] = $this->extractHost($uri);
  553.         $scheme parse_url($uri\PHP_URL_SCHEME);
  554.         $server['HTTPS'] = null === $scheme $server['HTTPS'] : 'https' === $scheme;
  555.         unset($server['HTTP_IF_NONE_MATCH'], $server['HTTP_IF_MODIFIED_SINCE']);
  556.         return $server;
  557.     }
  558.     private function extractHost(string $uri): ?string
  559.     {
  560.         $host parse_url($uri\PHP_URL_HOST);
  561.         if ($port parse_url($uri\PHP_URL_PORT)) {
  562.             return $host.':'.$port;
  563.         }
  564.         return $host;
  565.     }
  566. }