diff --git a/src/Configuration.php b/src/Configuration.php index 7f5de1a4..eaf6a490 100644 --- a/src/Configuration.php +++ b/src/Configuration.php @@ -4,6 +4,7 @@ namespace Pest\Browser; +use Pest\Browser\Contracts\HttpServer; use Pest\Browser\Enums\BrowserType; use Pest\Browser\Enums\ColorScheme; use Pest\Browser\Playwright\Playwright; @@ -114,4 +115,16 @@ public function diff(): self return $this; } + + /** + * Sets the browsers http server class. + * + * @param class-string $class + */ + public function httpServer(string $class): self + { + ServerManager::setHttpServerClass($class); + + return $this; + } } diff --git a/src/Drivers/SymfonyHttpServer.php b/src/Drivers/SymfonyHttpServer.php new file mode 100644 index 00000000..f42cd939 --- /dev/null +++ b/src/Drivers/SymfonyHttpServer.php @@ -0,0 +1,323 @@ +stop(); + } + + /** + * Rewrite the given URL to match the server's host and port. + */ + public function rewrite(string $url): string + { + if (! str_starts_with($url, 'http://') && ! str_starts_with($url, 'https://')) { + $url = mb_ltrim($url, '/'); + + $url = '/'.$url; + } + + $parts = parse_url($url); + $path = $parts['path'] ?? '/'; + $query = $parts['query'] ?? ''; + $fragment = $parts['fragment'] ?? ''; + + return $this->url().$path.($query !== '' ? '?'.$query : '').($fragment !== '' ? '#'.$fragment : ''); + } + + /** + * Start the server and listen for incoming connections. + */ + public function start(): void + { + if ($this->socket instanceof AmpHttpServer) { + return; + } + + $this->socket = $server = SocketHttpServer::createForDirectAccess(new NullLogger()); + + $server->expose("{$this->host}:{$this->port}"); + $server->start( + new ClosureRequestHandler($this->handleRequest(...)), + new DefaultErrorHandler(), + ); + } + + /** + * Stop the server and close all connections. + */ + public function stop(): void + { + // @codeCoverageIgnoreStart + if ($this->socket instanceof AmpHttpServer) { + $this->flush(); + + if ($this->socket instanceof AmpHttpServer) { + if (in_array($this->socket->getStatus(), [HttpServerStatus::Starting, HttpServerStatus::Started], true)) { + $this->socket->stop(); + } + + $this->socket = null; + } + } + } + + /** + * Flush pending requests and close all connections. + */ + public function flush(): void + { + if (! $this->socket instanceof AmpHttpServer) { + return; + } + + Execution::instance()->tick(); + + $this->lastThrowable = null; + } + + /** + * Bootstrap the server and set the application URL. + */ + public function bootstrap(): void + { + $this->start(); + + if (is_string($_ENV['DEFAULT_URI'] ?? null)) { + $this->setOriginalAssetUrl($_ENV['DEFAULT_URI']); + } + } + + /** + * Get the last throwable that occurred during the server's execution. + */ + public function lastThrowable(): ?Throwable + { + return $this->lastThrowable; + } + + /** + * Throws the last throwable if it should be thrown. + * + * @throws Throwable + */ + public function throwLastThrowableIfNeeded(): void + { + if (! $this->lastThrowable instanceof Throwable) { + return; + } + + throw $this->lastThrowable; + } + + /** + * Get the public path for the given path. + */ + private function url(): string + { + if (! $this->socket instanceof AmpHttpServer) { + throw new ServerNotFoundException('The HTTP server is not running.'); + } + + return sprintf('http://%s:%d', $this->host, $this->port); + } + + /** + * Sets the original asset URL. + */ + private function setOriginalAssetUrl(string $url): void + { + $this->originalAssetUrl = mb_rtrim($url, '/'); + } + + /** + * Handle the incoming request and return a response. + */ + private function handleRequest(AmpRequest $request): Response + { + GlobalState::flush(); + + if (Execution::instance()->isWaiting() === false) { + Execution::instance()->tick(); + } + + $publicPath = getcwd().DIRECTORY_SEPARATOR.(is_string($_ENV['PUBLIC_PATH'] ?? null) ? $_ENV['PUBLIC_PATH'] : 'public'); + $filepath = $publicPath.$request->getUri()->getPath(); + if (file_exists($filepath) && ! is_dir($filepath)) { + return $this->asset($filepath); + } + + $kernelClass = is_string($_ENV['KERNEL_CLASS'] ?? null) ? $_ENV['KERNEL_CLASS'] : 'App\Kernel'; + if (class_exists($kernelClass) === false) { + $this->lastThrowable = new Exception('You must define the test kernel class environment variable: KERNEL_CLASS.'); + + throw $this->lastThrowable; + } + /** @var KernelInterface&TerminableInterface $kernel */ + $kernel = new $kernelClass($_ENV['APP_ENV'], (bool) $_ENV['APP_DEBUG']); + + $contentType = $request->getHeader('content-type') ?? ''; + $method = mb_strtoupper($request->getMethod()); + $rawBody = (string) $request->getBody(); + $parameters = []; + if ($method !== 'GET' && str_starts_with(mb_strtolower($contentType), 'application/x-www-form-urlencoded')) { + parse_str($rawBody, $parameters); + } + + $symfonyRequest = Request::create( + (string) $request->getUri(), + $method, + $parameters, + $request->getCookies(), + [], // @TODO files... + [], // @TODO server variables... + $rawBody + ); + + $symfonyRequest->headers->add($request->getHeaders()); + + try { + $response = $kernel->handle($symfonyRequest); + } catch (Throwable $e) { + $this->lastThrowable = $e; + + throw $e; + } + + $kernel->terminate($symfonyRequest, $response); + + if (property_exists($response, 'exception') && $response->exception !== null) { + assert($response->exception instanceof Throwable); + + $this->lastThrowable = $response->exception; + } + + $content = $response->getContent(); + + if ($content === false) { + try { + ob_start(); + $response->sendContent(); + } finally { + // @phpstan-ignore-next-line + $content = mb_trim(ob_get_clean()); + } + } + + return new Response( + $response->getStatusCode(), + $response->headers->all(), // @phpstan-ignore-line + $content, + ); + } + + /** + * Return an asset response. + */ + private function asset(string $filepath): Response + { + $file = fopen($filepath, 'r'); + + if ($file === false) { + return new Response(404); + } + + $mimeTypes = new MimeTypes(); + $contentType = $mimeTypes->getMimeTypes(pathinfo($filepath, PATHINFO_EXTENSION)); + + $contentType = $contentType[0] ?? 'application/octet-stream'; + + if (str_ends_with($filepath, '.js') || str_ends_with($filepath, '.css')) { + $temporaryStream = fopen('php://temp', 'r+'); + assert($temporaryStream !== false, 'Failed to open temporary stream.'); + + // @phpstan-ignore-next-line + $temporaryContent = fread($file, (int) filesize($filepath)); + + assert($temporaryContent !== false, 'Failed to open temporary stream.'); + + $content = $this->rewriteAssetUrl($temporaryContent); + + fwrite($temporaryStream, $content); + + rewind($temporaryStream); + + $file = $temporaryStream; + } + + return new Response(200, [ + 'Content-Type' => $contentType, + ], new ReadableResourceStream($file)); + } + + /** + * Rewrite the asset URL in the given content. + */ + private function rewriteAssetUrl(string $content): string + { + if ($this->originalAssetUrl === null) { + return $content; + } + + return str_replace($this->originalAssetUrl, $this->url(), $content); + } +} diff --git a/src/ServerManager.php b/src/ServerManager.php index dc5c7d55..860227dd 100644 --- a/src/ServerManager.php +++ b/src/ServerManager.php @@ -41,6 +41,13 @@ final class ServerManager */ private ?HttpServer $http = null; + /** + * The HTTP server class. + * + * @param class-string $class + */ + private static ?string $httpServerClass = null; + /** * Gets the singleton instance of the server manager. */ @@ -49,6 +56,16 @@ public static function instance(): self return self::$instance ??= new self(); } + /** + * Sets the browsers http server class. + * + * @param class-string $class + */ + public static function setHttpServerClass(string $class): void + { + self::$httpServerClass = $class; + } + /** * Returns the Playwright server process instance. */ @@ -81,8 +98,11 @@ public function playwright(): PlaywrightServer */ public function http(): HttpServer { - return $this->http ??= match (function_exists('app_path')) { - true => new LaravelHttpServer( + $httpServer = self::$httpServerClass !== null ? new self::$httpServerClass(self::DEFAULT_HOST, Port::find()) : null; + + return $this->http ??= match (true) { + $httpServer instanceof HttpServer => $httpServer, + function_exists('app_path') => new LaravelHttpServer( self::DEFAULT_HOST, Port::find(), ),