From 575250dca62d14a19a3844010ec82827a62d4a6c Mon Sep 17 00:00:00 2001 From: Nicolas Forgeot Date: Mon, 20 Oct 2025 20:16:28 +0200 Subject: [PATCH 1/2] Copy SymfonyHttpServer from LaravelHttpServer --- src/Drivers/SymfonyHttpServer.php | 349 ++++++++++++++++++++++++++++++ 1 file changed, 349 insertions(+) create mode 100644 src/Drivers/SymfonyHttpServer.php diff --git a/src/Drivers/SymfonyHttpServer.php b/src/Drivers/SymfonyHttpServer.php new file mode 100644 index 00000000..37b8f996 --- /dev/null +++ b/src/Drivers/SymfonyHttpServer.php @@ -0,0 +1,349 @@ +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); + $queryParameters = []; + $path = $parts['path'] ?? '/'; + parse_str($parts['query'] ?? '', $queryParameters); + + return (string) Uri::of($this->url()) + ->withPath($path) + ->withQuery($queryParameters); + } + + /** + * 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(); + + $url = $this->url(); + + config(['app.url' => $url]); + + config(['cors.paths' => ['*']]); + + if (app()->bound('url')) { + $urlGenerator = app('url'); + + assert($urlGenerator instanceof UrlGenerator); + + $this->setOriginalAssetUrl($urlGenerator->asset('')); + + $urlGenerator->useOrigin($url); + $urlGenerator->useAssetOrigin($url); + $urlGenerator->forceScheme('http'); + } + } + + /** + * 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; + } + + $exceptionHandler = app(ExceptionHandler::class); + + if ($exceptionHandler instanceof WithoutExceptionHandlingHandler) { + 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(); + } + + $uri = $request->getUri(); + $path = in_array($uri->getPath(), ['', '0'], true) ? '/' : $uri->getPath(); + $query = $uri->getQuery() ?? ''; // @phpstan-ignore-line + $fullPath = $path.($query !== '' ? '?'.$query : ''); + $absoluteUrl = mb_rtrim($this->url(), '/').$fullPath; + + $filepath = public_path($path); + if (file_exists($filepath) && ! is_dir($filepath)) { + return $this->asset($filepath); + } + + $kernel = app()->make(HttpKernel::class); + + $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( + $absoluteUrl, + $method, + $parameters, + $request->getCookies(), + [], // @TODO files... + [], // @TODO server variables... + $rawBody + ); + + $symfonyRequest->headers->add($request->getHeaders()); + + $debug = config('app.debug'); + + try { + config(['app.debug' => false]); + + $response = $kernel->handle($laravelRequest = Request::createFromBase($symfonyRequest)); + } catch (Throwable $e) { + $this->lastThrowable = $e; + + throw $e; + } finally { + config(['app.debug' => $debug]); + } + + $kernel->terminate($laravelRequest, $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')) { + $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); + } +} From 25ca7da28e01f2d5b52ebc4617dd4d7d18ef1d88 Mon Sep 17 00:00:00 2001 From: Nicolas Forgeot Date: Mon, 20 Oct 2025 21:53:16 +0200 Subject: [PATCH 2/2] Implements SymfonyHttpServer --- src/Configuration.php | 13 ++++++ src/Drivers/SymfonyHttpServer.php | 76 ++++++++++--------------------- src/ServerManager.php | 24 +++++++++- 3 files changed, 60 insertions(+), 53 deletions(-) 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 index 37b8f996..f42cd939 100644 --- a/src/Drivers/SymfonyHttpServer.php +++ b/src/Drivers/SymfonyHttpServer.php @@ -12,17 +12,15 @@ use Amp\Http\Server\RequestHandler\ClosureRequestHandler; use Amp\Http\Server\Response; use Amp\Http\Server\SocketHttpServer; -use Illuminate\Contracts\Debug\ExceptionHandler; -use Illuminate\Contracts\Http\Kernel as HttpKernel; -use Illuminate\Foundation\Testing\Concerns\WithoutExceptionHandlingHandler; -use Illuminate\Http\Request; -use Illuminate\Routing\UrlGenerator; -use Illuminate\Support\Uri; +use Exception; use Pest\Browser\Contracts\HttpServer; use Pest\Browser\Exceptions\ServerNotFoundException; use Pest\Browser\Execution; use Pest\Browser\GlobalState; use Psr\Log\NullLogger; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\KernelInterface; +use Symfony\Component\HttpKernel\TerminableInterface; use Symfony\Component\Mime\MimeTypes; use Throwable; @@ -49,7 +47,7 @@ final class SymfonyHttpServer implements HttpServer private ?Throwable $lastThrowable = null; /** - * Creates a new laravel http server instance. + * Creates a new Symfony http server instance. */ public function __construct( public readonly string $host, @@ -79,13 +77,11 @@ public function rewrite(string $url): string } $parts = parse_url($url); - $queryParameters = []; $path = $parts['path'] ?? '/'; - parse_str($parts['query'] ?? '', $queryParameters); + $query = $parts['query'] ?? ''; + $fragment = $parts['fragment'] ?? ''; - return (string) Uri::of($this->url()) - ->withPath($path) - ->withQuery($queryParameters); + return $this->url().$path.($query !== '' ? '?'.$query : '').($fragment !== '' ? '#'.$fragment : ''); } /** @@ -146,22 +142,8 @@ public function bootstrap(): void { $this->start(); - $url = $this->url(); - - config(['app.url' => $url]); - - config(['cors.paths' => ['*']]); - - if (app()->bound('url')) { - $urlGenerator = app('url'); - - assert($urlGenerator instanceof UrlGenerator); - - $this->setOriginalAssetUrl($urlGenerator->asset('')); - - $urlGenerator->useOrigin($url); - $urlGenerator->useAssetOrigin($url); - $urlGenerator->forceScheme('http'); + if (is_string($_ENV['DEFAULT_URI'] ?? null)) { + $this->setOriginalAssetUrl($_ENV['DEFAULT_URI']); } } @@ -184,11 +166,7 @@ public function throwLastThrowableIfNeeded(): void return; } - $exceptionHandler = app(ExceptionHandler::class); - - if ($exceptionHandler instanceof WithoutExceptionHandlingHandler) { - throw $this->lastThrowable; - } + throw $this->lastThrowable; } /** @@ -222,18 +200,20 @@ private function handleRequest(AmpRequest $request): Response Execution::instance()->tick(); } - $uri = $request->getUri(); - $path = in_array($uri->getPath(), ['', '0'], true) ? '/' : $uri->getPath(); - $query = $uri->getQuery() ?? ''; // @phpstan-ignore-line - $fullPath = $path.($query !== '' ? '?'.$query : ''); - $absoluteUrl = mb_rtrim($this->url(), '/').$fullPath; - - $filepath = public_path($path); + $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); } - $kernel = app()->make(HttpKernel::class); + $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()); @@ -244,7 +224,7 @@ private function handleRequest(AmpRequest $request): Response } $symfonyRequest = Request::create( - $absoluteUrl, + (string) $request->getUri(), $method, $parameters, $request->getCookies(), @@ -255,21 +235,15 @@ private function handleRequest(AmpRequest $request): Response $symfonyRequest->headers->add($request->getHeaders()); - $debug = config('app.debug'); - try { - config(['app.debug' => false]); - - $response = $kernel->handle($laravelRequest = Request::createFromBase($symfonyRequest)); + $response = $kernel->handle($symfonyRequest); } catch (Throwable $e) { $this->lastThrowable = $e; throw $e; - } finally { - config(['app.debug' => $debug]); } - $kernel->terminate($laravelRequest, $response); + $kernel->terminate($symfonyRequest, $response); if (property_exists($response, 'exception') && $response->exception !== null) { assert($response->exception instanceof Throwable); @@ -312,7 +286,7 @@ private function asset(string $filepath): Response $contentType = $contentType[0] ?? 'application/octet-stream'; - if (str_ends_with($filepath, '.js')) { + if (str_ends_with($filepath, '.js') || str_ends_with($filepath, '.css')) { $temporaryStream = fopen('php://temp', 'r+'); assert($temporaryStream !== false, 'Failed to open temporary stream.'); 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(), ),