From e6ba8cb2fc1ce93e83c9042490bc064ff183d94a Mon Sep 17 00:00:00 2001 From: Chris Morrell Date: Tue, 8 Apr 2025 10:55:19 -0400 Subject: [PATCH 1/4] Proof of concept --- composer.json | 4 + src/Browser.php | 4 + src/Concerns/ProvidesProxyServer.php | 24 +++ src/Driver/AsyncCommandExecutor.php | 180 +++++++++++++++++ src/Driver/AsyncWebDriver.php | 57 ++++++ src/Driver/AsyncWebDriverFactory.php | 125 ++++++++++++ src/DuskServiceProvider.php | 31 +++ src/Http/ProxyServer.php | 279 +++++++++++++++++++++++++++ src/TestCase.php | 9 +- stubs/DuskTestCase.stub | 9 +- tests/Browser/DuskTestCase.php | 8 +- 11 files changed, 719 insertions(+), 11 deletions(-) create mode 100644 src/Concerns/ProvidesProxyServer.php create mode 100644 src/Driver/AsyncCommandExecutor.php create mode 100644 src/Driver/AsyncWebDriver.php create mode 100644 src/Driver/AsyncWebDriverFactory.php create mode 100644 src/Http/ProxyServer.php diff --git a/composer.json b/composer.json index 32963f6a0..bba93e852 100644 --- a/composer.json +++ b/composer.json @@ -17,13 +17,17 @@ "php": "^8.1", "ext-json": "*", "ext-zip": "*", + "ext-sockets": "*", "guzzlehttp/guzzle": "^7.5", "illuminate/console": "^10.0|^11.0|^12.0", "illuminate/support": "^10.0|^11.0|^12.0", "php-webdriver/webdriver": "^1.15.2", + "react/event-loop": "^1.5", + "react/http": "^1.10", "symfony/console": "^6.2|^7.0", "symfony/finder": "^6.2|^7.0", "symfony/process": "^6.2|^7.0", + "symfony/psr-http-message-bridge": "^7.1", "vlucas/phpdotenv": "^5.2" }, "require-dev": { diff --git a/src/Browser.php b/src/Browser.php index 338e60e9f..9bb225aaf 100644 --- a/src/Browser.php +++ b/src/Browser.php @@ -10,6 +10,7 @@ use Facebook\WebDriver\WebDriverPoint; use Illuminate\Support\Str; use Illuminate\Support\Traits\Macroable; +use Laravel\Dusk\Http\ProxyServer; class Browser { @@ -182,6 +183,9 @@ public function visit($url) $url = static::$baseUrl.'/'.ltrim($url, '/'); } + // Force our proxy server — this needs to be improved + $url = str_replace(rtrim(static::$baseUrl, '/'), app(ProxyServer::class)->url(), $url); + $this->driver->navigate()->to($url); // If the page variable was set, we will call the "on" method which will set a diff --git a/src/Concerns/ProvidesProxyServer.php b/src/Concerns/ProvidesProxyServer.php new file mode 100644 index 000000000..84fb6d310 --- /dev/null +++ b/src/Concerns/ProvidesProxyServer.php @@ -0,0 +1,24 @@ +afterApplicationCreated(function () { + $proxy = $this->app->make(ProxyServer::class)->listen(); + $this->app->make(UrlGenerator::class)->forceRootUrl($proxy->url()); + }); + + $this->beforeApplicationDestroyed(function () { + $this->app->make(ProxyServer::class)->flush(); + $this->app->make(UrlGenerator::class)->forceRootUrl(null); + }); + } +} diff --git a/src/Driver/AsyncCommandExecutor.php b/src/Driver/AsyncCommandExecutor.php new file mode 100644 index 000000000..17a2c659d --- /dev/null +++ b/src/Driver/AsyncCommandExecutor.php @@ -0,0 +1,180 @@ +extractRequestDataFromCommand($command); + + return $this->sendRequestAndWaitForResponse(match ($method) { + 'GET' => $client->get($url, $headers), + 'POST' => $client->post($url, $headers, $this->encodePayload($payload)), + 'DELETE' => $client->delete($url, $headers), + }); + } + + /** + * Run event loop until request is fulfilled. + * + * @param PromiseInterface $request + * @return WebDriverResponse + * + * @throws JsonException + * @throws WebDriverException + */ + protected function sendRequestAndWaitForResponse(PromiseInterface $request): WebDriverResponse + { + $resolved = null; + + $request->then(function ($response) use (&$resolved) { + Loop::get()->futureTick(fn() => Loop::stop()); + $resolved = $response; + }); + + while ($resolved === null) { + Loop::run(); + } + + return $this->mapAsyncResponseToWebDriverResponse($resolved); + } + + /** + * Parse HTTP response and map to web driver response. + * + * @param ResponseInterface $response + * @return WebDriverResponse + * + * @throws JsonException + * @throws WebDriverException + */ + protected function mapAsyncResponseToWebDriverResponse(ResponseInterface $response): WebDriverResponse + { + $value = null; + $message = null; + $sessionId = null; + $status = 0; + + $results = json_decode($response->getBody(), true, 512, JSON_THROW_ON_ERROR); + + if (is_array($results)) { + $value = Arr::get($results, 'value'); + $message = Arr::get($results, 'message'); + $status = Arr::get($results, 'status', 0); + + if (is_array($value) && array_key_exists('sessionId', $value)) { + $sessionId = $value['sessionId']; + } elseif (array_key_exists('sessionId', $results)) { + $sessionId = $results['sessionId']; + } + } + + if (is_array($value) && isset($value['error'])) { + WebDriverException::throwException($value['error'], $message, $results); + } + + if ($status !== 0) { + WebDriverException::throwException($status, $message, $results); + } + + return (new WebDriverResponse($sessionId))->setStatus($status)->setValue($value); + } + + /** + * Ensure that payload is always a JSON object. + * + * @param Collection $payload + * @return string + */ + protected function encodePayload(Collection $payload): string + { + // POST body must be valid JSON object, even if empty: https://www.w3.org/TR/webdriver/#processing-model + if ($payload->isEmpty()) { + return '{}'; + } + + return $payload->toJson(); + } + + /** + * Extract data necessary to make HTTP request for web driver command. + * + * @param WebDriverCommand $command + * @return array{0: string, 1: string, 2: array, 3: Collection} + * + * @throws LogicException + */ + protected function extractRequestDataFromCommand(WebDriverCommand $command): array + { + ['url' => $path, 'method' => $method] = $this->getCommandHttpOptions($command); + + // Keys that are prefixed with ":" are URL parameters. All others are JSON payload data. + [$parameters, $payload] = collect($command->getParameters() ?? []) + ->put(':sessionId', (string) $command->getSessionID()) + ->partition(fn($value, $key) => str_starts_with($key, ':')); + + if ($payload->isNotEmpty() && $method !== 'POST') { + throw LogicException::forInvalidHttpMethod($path, $method, $payload->all()); + } + + $url = $this->url.$this->applyParametersToPath($parameters, $path); + $method = strtoupper($method); + $headers = $this->defaultHeaders($method); + + return [$url, $method, $headers, $payload]; + } + + /** + * Replace prefixed placeholders with request parameters. + * + * @param Collection $parameters + * @param string $path + * @return string + */ + protected function applyParametersToPath(Collection $parameters, string $path): string + { + return str_replace($parameters->keys()->all(), $parameters->values()->all(), $path); + } + + /** + * Get the default HTTP headers for a given request method. + * + * @param string $method + * @return array + */ + protected function defaultHeaders(string $method): array + { + $headers = collect(static::DEFAULT_HTTP_HEADERS)->mapWithKeys(function ($header) { + [$key, $value] = explode(':', $header, 2); + return [$key => $value]; + }); + + if (in_array($method, ['POST', 'PUT'], true)) { + $headers->put('Expect', ''); + } + + return $headers->all(); + } +} diff --git a/src/Driver/AsyncWebDriver.php b/src/Driver/AsyncWebDriver.php new file mode 100644 index 000000000..d0a210b3d --- /dev/null +++ b/src/Driver/AsyncWebDriver.php @@ -0,0 +1,57 @@ +seleniumServerUrl = rtrim($this->seleniumServerUrl, '/'); + + $this->desiredCapabilities = match (true) { + $desiredCapabilities instanceof DesiredCapabilities => $desiredCapabilities, + is_array($desiredCapabilities) => new DesiredCapabilities($desiredCapabilities), + default => new DesiredCapabilities(), + }; + } + + /** + * Create and initialize new AsyncWebDriver. + * + * @return AsyncWebDriver + */ + public function __invoke(): AsyncWebDriver + { + $this->initializeSession(); + + $executor = new AsyncCommandExecutor($this->seleniumServerUrl, $this->httpProxy, $this->httpProxyPort); + + $this->configureExecutor($executor); + + return new AsyncWebDriver($executor, $this->sessionId, $this->sessionCapabilities, $this->isW3cCompliant); + } + + /** + * Initialize the web driver session synchronously. + * + * @return void + */ + protected function initializeSession(): void + { + $executor = $this->configureExecutor( + new HttpCommandExecutor($this->seleniumServerUrl, $this->httpProxy, $this->httpProxyPort), + ); + + $response = $executor->execute(WebDriverCommand::newSession($this->parameters())); + $value = $response->getValue(); + + $this->isW3cCompliant = isset($value['capabilities']); + + $this->sessionCapabilities = $this->isW3cCompliant + ? DesiredCapabilities::createFromW3cCapabilities($value['capabilities']) + : new DesiredCapabilities($value['capabilities']); + + $this->sessionId = $response->getSessionID(); + } + + /** + * Apply timeouts/configuration to the command executor. + * + * @param HttpCommandExecutor $executor + * @return HttpCommandExecutor + */ + protected function configureExecutor(HttpCommandExecutor $executor): HttpCommandExecutor + { + if (!is_null($this->connectionTimeoutMs)) { + $executor->setConnectionTimeout($this->connectionTimeoutMs); + } + + if (!is_null($this->requestTimeoutMs)) { + $executor->setRequestTimeout($this->requestTimeoutMs); + } + + return $executor; + } + + /** + * Convert desired/required capabilities into session parameters. + * + * @return array + */ + protected function parameters(): array + { + // Set W3C parameters first + $parameters = [ + 'capabilities' => [ + 'firstMatch' => [ + (object) $this->desiredCapabilities->toW3cCompatibleArray(), + ], + ], + ]; + + // Handle *required* params + if ($this->requiredCapabilities && count($this->requiredCapabilities->toArray())) { + $parameters['capabilities']['alwaysMatch'] = (object) $this->requiredCapabilities->toW3cCompatibleArray(); + $this->desiredCapabilities->setCapability('requiredCapabilities', + (object) $this->requiredCapabilities->toArray()); + } + + $parameters['desiredCapabilities'] = (object) $this->desiredCapabilities->toArray(); + + return $parameters; + } +} diff --git a/src/DuskServiceProvider.php b/src/DuskServiceProvider.php index cd3bb145c..cd2eebac6 100644 --- a/src/DuskServiceProvider.php +++ b/src/DuskServiceProvider.php @@ -2,11 +2,28 @@ namespace Laravel\Dusk; +use Illuminate\Contracts\Http\Kernel as HttpKernel; use Illuminate\Support\Facades\Route; use Illuminate\Support\ServiceProvider; +use Laravel\Dusk\Http\ProxyServer; +use React\EventLoop\Loop; +use Symfony\Bridge\PsrHttpMessage\Factory\HttpFoundationFactory; class DuskServiceProvider extends ServiceProvider { + public function register() + { + $this->app->singleton(ProxyServer::class, function($app) { + return new ProxyServer( + kernel: $app->make(HttpKernel::class), + loop: Loop::get(), + factory: $app->make(HttpFoundationFactory::class), + host: config('dusk.proxy.host', '127.0.0.1'), + port: config('dusk.proxy.port', $this->findOpenPort(...)), + ); + }); + } + /** * Bootstrap any package services. * @@ -50,4 +67,18 @@ public function boot() ]); } } + + /** + * Find an available port to listen on. + * + * @return int + */ + protected function findOpenPort(): int + { + $sock = socket_create_listen(0); + socket_getsockname($sock, $addr, $port); + socket_close($sock); + + return $port; + } } diff --git a/src/Http/ProxyServer.php b/src/Http/ProxyServer.php new file mode 100644 index 000000000..2413fafed --- /dev/null +++ b/src/Http/ProxyServer.php @@ -0,0 +1,279 @@ +socket)) { + $this->socket = new SocketServer("{$this->host}:{$this->port}", [], $this->loop); + + $this->socket->on('connection', function (ConnectionInterface $connection) { + $this->connections[] = $connection; + }); + + $server = new ReactHttpServer( + $this->loop, + new StreamingRequestMiddleware(), + new LimitConcurrentRequestsMiddleware(100), + new RequestBodyBufferMiddleware(32 * 1024 * 1024), // 32 MB + new RequestBodyParserMiddleware(32 * 1024 * 1024, 100), // 32 MB + $this->handleRequest(...), + ); + + $server->listen($this->socket); + } + + return $this; + } + + /** + * Get the proxy server base URL. + * + * @return string + */ + public function url(): string + { + return "http://{$this->host}:{$this->port}"; + } + + /** + * Handle the request. + * + * @param ServerRequestInterface $psr_request + * @return Promise|Response + */ + protected function handleRequest(ServerRequestInterface $psr_request): Promise|Response + { + // If this is just a request for a static asset, just stream that content back + if ($static_response = $this->staticResponse($psr_request)) { + return $static_response; + } + + $promise = $this->runRequestThroughKernel( + Request::createFromBase($this->factory->createRequest($psr_request)), + ); + + // Handle exception + $promise->catch(function (Throwable $exception) { + return Response::plaintext($exception->getMessage()."\n".$exception->getTraceAsString()) + ->withStatus(Response::STATUS_INTERNAL_SERVER_ERROR); + }); + + return $promise; + } + + /** + * Pass a dynamic request to the Kernel. + * + * @param Request $request + * @return Promise + */ + protected function runRequestThroughKernel(Request $request): Promise + { + $this->requestsInFlight++; + + return new Promise(function (callable $resolve) use ($request) { + $this->loop->futureTick(fn() => $this->loop->stop()); + + $response = $this->kernel->handle($request); + + $resolve(new Response( + status: $response->getStatusCode(), + headers: $this->normalizeResponseHeaders($response->headers), + body: $this->getResponseContent($response), + version: $response->getProtocolVersion(), + )); + + $this->kernel->terminate($request, $response); + + $this->requestsInFlight--; + }); + } + + /** + * Extract the content from a Symfony response for async use. + * + * @param SymfonyResponse $response + * @return string + */ + protected function getResponseContent(SymfonyResponse $response): string + { + ob_start(); + + $response->sendContent(); + + return ob_get_clean(); + } + + /** + * Normalize Symfony headers for async use. + * + * @param ResponseHeaderBag $bag + * @return array + */ + protected function normalizeResponseHeaders(ResponseHeaderBag $bag): array + { + $headers = $bag->all(); + + if (!empty($cookies = $bag->getCookies())) { + $headers['Set-Cookie'] = []; + foreach ($cookies as $cookie) { + $headers['Set-Cookie'][] = (string) $cookie; + } + } + + return $headers; + } + + /** + * Return a static response if the request is for a public asset. + * + * @param ServerRequestInterface $request + * @return Promise|null + */ + protected function staticResponse(ServerRequestInterface $request): ?Promise + { + $path = $request->getUri()->getPath(); + + if (Str::contains($path, '../')) { + return null; + } + + $filepath = public_path($path); + + if (file_exists($filepath) && !is_dir($filepath)) { + $this->requestsInFlight++; + + return new Promise(function (callable $resolve) use ($filepath) { + $resolve(new Response(status: 200, headers: [ + 'Content-Type' => match (pathinfo($filepath, PATHINFO_EXTENSION)) { + 'css' => 'text/css', + 'js' => 'application/javascript', + 'png' => 'image/png', + 'jpg', 'jpeg' => 'image/jpeg', + 'svg' => 'image/svg+xml', + 'woff' => 'font/woff', + 'woff2' => 'font/woff2', + 'eot' => 'application/vnd.ms-fontobject', + 'ttf' => 'font/ttf', + default => (new MimeTypes())->guessMimeType($filepath), + }, + ], body: new ReadableResourceStream(fopen($filepath, 'r')))); + + $this->requestsInFlight--; + }); + } + + return null; + } + + /** + * Flush pending requests and close all connections. + * + * @return void + */ + public function flush(): void + { + if ($this->flushing) { + return; + } + + $this->flushing = true; + + $this->loop->addPeriodicTimer(0.1, function (TimerInterface $timer) { + if ($this->requestsInFlight === 0) { + foreach ($this->connections as $connection) { + $connection->close(); + } + $this->connections = []; + $this->socket->close(); + $this->loop->cancelTimer($timer); + } + }); + + $this->loop->run(); + } + + /** + * Ensure that connections are flushed when server is destroyed. + */ + public function __destruct() + { + $this->flush(); + } +} diff --git a/src/TestCase.php b/src/TestCase.php index 7fa8eb8fd..4a94b40c8 100644 --- a/src/TestCase.php +++ b/src/TestCase.php @@ -8,10 +8,13 @@ use Illuminate\Foundation\Testing\TestCase as FoundationTestCase; use Laravel\Dusk\Chrome\SupportsChrome; use Laravel\Dusk\Concerns\ProvidesBrowser; +use Laravel\Dusk\Concerns\ProvidesProxyServer; +use Laravel\Dusk\Driver\AsyncWebDriver; +use Laravel\Dusk\Driver\AsyncWebDriverFactory; abstract class TestCase extends FoundationTestCase { - use ProvidesBrowser, SupportsChrome; + use ProvidesBrowser, ProvidesProxyServer, SupportsChrome; /** * Register the base URL with Dusk. @@ -38,11 +41,11 @@ protected function setUp(): void /** * Create the RemoteWebDriver instance. * - * @return \Facebook\WebDriver\Remote\RemoteWebDriver + * @return \Laravel\Dusk\Driver\AsyncWebDriver */ protected function driver() { - return RemoteWebDriver::create( + return AsyncWebDriver::create( $_ENV['DUSK_DRIVER_URL'] ?? env('DUSK_DRIVER_URL') ?? 'http://localhost:9515', DesiredCapabilities::chrome() ); diff --git a/stubs/DuskTestCase.stub b/stubs/DuskTestCase.stub index d34b71552..f8fb345f1 100644 --- a/stubs/DuskTestCase.stub +++ b/stubs/DuskTestCase.stub @@ -4,8 +4,8 @@ namespace Tests; use Facebook\WebDriver\Chrome\ChromeOptions; use Facebook\WebDriver\Remote\DesiredCapabilities; -use Facebook\WebDriver\Remote\RemoteWebDriver; use Illuminate\Support\Collection; +use Laravel\Dusk\Driver\AsyncWebDriver; use Laravel\Dusk\TestCase as BaseTestCase; use PHPUnit\Framework\Attributes\BeforeClass; @@ -25,13 +25,14 @@ abstract class DuskTestCase extends BaseTestCase } /** - * Create the RemoteWebDriver instance. + * Create the AsyncWebDriver instance. */ - protected function driver(): RemoteWebDriver + protected function driver(): AsyncWebDriver { $options = (new ChromeOptions)->addArguments(collect([ $this->shouldStartMaximized() ? '--start-maximized' : '--window-size=1920,1080', '--disable-search-engine-choice-screen', + '--disable-smooth-scrolling', ])->unless($this->hasHeadlessDisabled(), function (Collection $items) { return $items->merge([ '--disable-gpu', @@ -39,7 +40,7 @@ abstract class DuskTestCase extends BaseTestCase ]); })->all()); - return RemoteWebDriver::create( + return AsyncWebDriver::create( $_ENV['DUSK_DRIVER_URL'] ?? env('DUSK_DRIVER_URL') ?? 'http://localhost:9515', DesiredCapabilities::chrome()->setCapability( ChromeOptions::CAPABILITY, $options diff --git a/tests/Browser/DuskTestCase.php b/tests/Browser/DuskTestCase.php index 48207880d..e1ac43a1b 100644 --- a/tests/Browser/DuskTestCase.php +++ b/tests/Browser/DuskTestCase.php @@ -7,6 +7,7 @@ use Facebook\WebDriver\Remote\RemoteWebDriver; use Illuminate\Support\Collection; use Laravel\Dusk\Browser; +use Laravel\Dusk\Driver\AsyncWebDriver; use Laravel\Dusk\TestCase; use Orchestra\Testbench\Concerns\CreatesApplication; use PHPUnit\Framework\Attributes\BeforeClass; @@ -58,6 +59,7 @@ protected function driver(): RemoteWebDriver $options = (new ChromeOptions)->addArguments(collect([ $this->shouldStartMaximized() ? '--start-maximized' : '--window-size=1920,1080', '--disable-search-engine-choice-screen', + '--disable-smooth-scrolling', ])->unless($this->hasHeadlessDisabled(), function (Collection $items) { return $items->merge([ '--disable-gpu', @@ -65,11 +67,9 @@ protected function driver(): RemoteWebDriver ]); })->all()); - return RemoteWebDriver::create( + return AsyncWebDriver::create( $_ENV['DUSK_DRIVER_URL'] ?? 'http://localhost:9515', - DesiredCapabilities::chrome()->setCapability( - ChromeOptions::CAPABILITY, $options - ) + DesiredCapabilities::chrome()->setCapability(ChromeOptions::CAPABILITY, $options) ); } From c4e55be7867b476d3f61d16fb8f2e7ca6b961302 Mon Sep 17 00:00:00 2001 From: Chris Morrell Date: Wed, 9 Apr 2025 10:59:49 -0400 Subject: [PATCH 2/4] Account for non-200 responses from chromedriver --- src/Driver/AsyncCommandExecutor.php | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/Driver/AsyncCommandExecutor.php b/src/Driver/AsyncCommandExecutor.php index 17a2c659d..f2f64681f 100644 --- a/src/Driver/AsyncCommandExecutor.php +++ b/src/Driver/AsyncCommandExecutor.php @@ -13,7 +13,9 @@ use Psr\Http\Message\ResponseInterface; use React\EventLoop\Loop; use React\Http\Browser as ReactHttpClient; +use React\Http\Message\ResponseException; use React\Promise\PromiseInterface; +use Throwable; class AsyncCommandExecutor extends HttpCommandExecutor { @@ -25,7 +27,7 @@ class AsyncCommandExecutor extends HttpCommandExecutor */ public function execute(WebDriverCommand $command): WebDriverResponse { - $client = new ReactHttpClient(); + $client = (new ReactHttpClient())->withRejectErrorResponse(false); [$url, $method, $headers, $payload] = $this->extractRequestDataFromCommand($command); @@ -54,6 +56,14 @@ protected function sendRequestAndWaitForResponse(PromiseInterface $request): Web $resolved = $response; }); + $request->catch(function (Throwable $exception) use (&$resolved) { + if ($resolved instanceof ResponseException) { + $resolved = $exception->getResponse(); + } else { + throw $exception; + } + }); + while ($resolved === null) { Loop::run(); } From 93d76b631ac5f0805aee9425ee1a8c6cb1714d08 Mon Sep 17 00:00:00 2001 From: Chris Morrell Date: Wed, 9 Apr 2025 11:43:35 -0400 Subject: [PATCH 3/4] Account for domain-level routing --- src/Browser.php | 5 +- src/Concerns/ProvidesProxyServer.php | 11 ++-- src/DuskServiceProvider.php | 12 ++++ src/Http/ProxyServer.php | 3 + src/Http/UrlGenerator.php | 98 ++++++++++++++++++++++++++++ 5 files changed, 123 insertions(+), 6 deletions(-) create mode 100644 src/Http/UrlGenerator.php diff --git a/src/Browser.php b/src/Browser.php index 9bb225aaf..fb251ccaa 100644 --- a/src/Browser.php +++ b/src/Browser.php @@ -11,6 +11,7 @@ use Illuminate\Support\Str; use Illuminate\Support\Traits\Macroable; use Laravel\Dusk\Http\ProxyServer; +use Laravel\Dusk\Http\UrlGenerator; class Browser { @@ -183,8 +184,8 @@ public function visit($url) $url = static::$baseUrl.'/'.ltrim($url, '/'); } - // Force our proxy server — this needs to be improved - $url = str_replace(rtrim(static::$baseUrl, '/'), app(ProxyServer::class)->url(), $url); + // Pass the request through our proxy + $url = app(UrlGenerator::class)->proxy($url); $this->driver->navigate()->to($url); diff --git a/src/Concerns/ProvidesProxyServer.php b/src/Concerns/ProvidesProxyServer.php index 84fb6d310..d52db3b84 100644 --- a/src/Concerns/ProvidesProxyServer.php +++ b/src/Concerns/ProvidesProxyServer.php @@ -2,8 +2,10 @@ namespace Laravel\Dusk\Concerns; -use Illuminate\Routing\UrlGenerator; +use Illuminate\Contracts\Routing\UrlGenerator as UrlGeneratorContract; +use Illuminate\Routing\UrlGenerator as BaseUrlGenerator; use Laravel\Dusk\Http\ProxyServer; +use Laravel\Dusk\Http\UrlGenerator; use PHPUnit\Framework\Attributes\Before; trait ProvidesProxyServer @@ -12,13 +14,14 @@ trait ProvidesProxyServer public function setUpProvidesProxyServer(): void { $this->afterApplicationCreated(function () { - $proxy = $this->app->make(ProxyServer::class)->listen(); - $this->app->make(UrlGenerator::class)->forceRootUrl($proxy->url()); + $this->app->make(ProxyServer::class)->listen(); + $this->app->instance('url', $this->app->make(UrlGenerator::class)); + $this->app->instance(UrlGeneratorContract::class, $this->app->make(UrlGenerator::class)); + $this->app->instance(BaseUrlGenerator::class, $this->app->make(UrlGenerator::class)); }); $this->beforeApplicationDestroyed(function () { $this->app->make(ProxyServer::class)->flush(); - $this->app->make(UrlGenerator::class)->forceRootUrl(null); }); } } diff --git a/src/DuskServiceProvider.php b/src/DuskServiceProvider.php index cd2eebac6..15f0729e0 100644 --- a/src/DuskServiceProvider.php +++ b/src/DuskServiceProvider.php @@ -3,9 +3,13 @@ namespace Laravel\Dusk; use Illuminate\Contracts\Http\Kernel as HttpKernel; +use Illuminate\Contracts\Routing\UrlGenerator as UrlGeneratorContract; +use Illuminate\Foundation\Application; +use Illuminate\Routing\RouteCollectionInterface; use Illuminate\Support\Facades\Route; use Illuminate\Support\ServiceProvider; use Laravel\Dusk\Http\ProxyServer; +use Laravel\Dusk\Http\UrlGenerator; use React\EventLoop\Loop; use Symfony\Bridge\PsrHttpMessage\Factory\HttpFoundationFactory; @@ -22,6 +26,14 @@ public function register() port: config('dusk.proxy.port', $this->findOpenPort(...)), ); }); + + $this->app->singleton(UrlGenerator::class, function (Application $app) { + return new UrlGenerator( + endpoint: $app->make(ProxyServer::class)->url(), + appHost: parse_url(config('app.url'), PHP_URL_HOST), + url: $app->make(UrlGeneratorContract::class), + ); + }); } /** diff --git a/src/Http/ProxyServer.php b/src/Http/ProxyServer.php index 2413fafed..bfaa01797 100644 --- a/src/Http/ProxyServer.php +++ b/src/Http/ProxyServer.php @@ -10,6 +10,7 @@ use React\EventLoop\TimerInterface; use React\Http\HttpServer as ReactHttpServer; use React\Http\Message\Response; +use React\Http\Message\Uri; use React\Http\Middleware\LimitConcurrentRequestsMiddleware; use React\Http\Middleware\RequestBodyBufferMiddleware; use React\Http\Middleware\RequestBodyParserMiddleware; @@ -119,6 +120,8 @@ public function url(): string */ protected function handleRequest(ServerRequestInterface $psr_request): Promise|Response { + $psr_request = $psr_request->withUri(new Uri($psr_request->getQueryParams()['url'])); + // If this is just a request for a static asset, just stream that content back if ($static_response = $this->staticResponse($psr_request)) { return $static_response; diff --git a/src/Http/UrlGenerator.php b/src/Http/UrlGenerator.php new file mode 100644 index 000000000..1e5664dac --- /dev/null +++ b/src/Http/UrlGenerator.php @@ -0,0 +1,98 @@ +__call(__FUNCTION__, func_get_args()); + } + + public function previous($fallback = false) + { + return $this->__call(__FUNCTION__, func_get_args()); + } + + public function to($path, $extra = [], $secure = null) + { + return $this->__call(__FUNCTION__, func_get_args()); + } + + public function secure($path, $parameters = []) + { + return $this->__call(__FUNCTION__, func_get_args()); + } + + public function asset($path, $secure = null) + { + return $this->__call(__FUNCTION__, func_get_args()); + } + + public function route($name, $parameters = [], $absolute = true) + { + return $this->__call(__FUNCTION__, func_get_args()); + } + + public function signedRoute($name, $parameters = [], $expiration = null, $absolute = true) + { + return $this->__call(__FUNCTION__, func_get_args()); + } + + public function temporarySignedRoute($name, $expiration, $parameters = [], $absolute = true) + { + return $this->__call(__FUNCTION__, func_get_args()); + } + + public function action($action, $parameters = [], $absolute = true) + { + return $this->__call(__FUNCTION__, func_get_args()); + } + + public function getRootControllerNamespace() + { + return $this->__call(__FUNCTION__, func_get_args()); + } + + public function setRootControllerNamespace($rootNamespace) + { + return $this->__call(__FUNCTION__, func_get_args()); + } + + public function proxy(string $url): string + { + // TODO: Provide a way to register a callback that allows for more complex matching + + $host = parse_url($url, PHP_URL_HOST); + + return $host === $this->appHost + ? $this->endpoint.'?url='.urlencode($url) + : $url; + } + + public function __call(string $name, array $arguments) + { + $result = $this->forwardDecoratedCallTo($this->url, $name, $arguments); + + if (is_string($result) && filter_var($result, FILTER_VALIDATE_URL)) { + return $this->proxy($result); + } + + return $result; + } +} From 433bec9d679f0c2d1c66e38a28b102f437e1e795 Mon Sep 17 00:00:00 2001 From: Chris Morrell Date: Wed, 9 Apr 2025 15:06:28 -0400 Subject: [PATCH 4/4] Account for client-side routing --- src/Browser.php | 12 ++++++++++- src/DuskServiceProvider.php | 5 ++++- src/Http/ProxyServer.php | 40 ++++++++++++++++++++----------------- src/Http/UrlGenerator.php | 23 ++++++++++++++++----- 4 files changed, 55 insertions(+), 25 deletions(-) diff --git a/src/Browser.php b/src/Browser.php index fb251ccaa..ebf57a67b 100644 --- a/src/Browser.php +++ b/src/Browser.php @@ -12,6 +12,7 @@ use Illuminate\Support\Traits\Macroable; use Laravel\Dusk\Http\ProxyServer; use Laravel\Dusk\Http\UrlGenerator; +use React\EventLoop\Loop; class Browser { @@ -680,7 +681,16 @@ public function onComponent($component, $parentResolver) */ public function pause($milliseconds) { - usleep($milliseconds * 1000); + $sleeping = true; + + Loop::addTimer($milliseconds / 1000, function () use (&$sleeping) { + $sleeping = false; + Loop::stop(); + }); + + while ($sleeping) { + Loop::run(); + } return $this; } diff --git a/src/DuskServiceProvider.php b/src/DuskServiceProvider.php index 15f0729e0..939e0ad97 100644 --- a/src/DuskServiceProvider.php +++ b/src/DuskServiceProvider.php @@ -28,8 +28,11 @@ public function register() }); $this->app->singleton(UrlGenerator::class, function (Application $app) { + $proxy = $app->make(ProxyServer::class); + return new UrlGenerator( - endpoint: $app->make(ProxyServer::class)->url(), + proxyHostname: $proxy->host, + proxyPort: $proxy->port, appHost: parse_url(config('app.url'), PHP_URL_HOST), url: $app->make(UrlGeneratorContract::class), ); diff --git a/src/Http/ProxyServer.php b/src/Http/ProxyServer.php index bfaa01797..19d0c0b3a 100644 --- a/src/Http/ProxyServer.php +++ b/src/Http/ProxyServer.php @@ -10,7 +10,6 @@ use React\EventLoop\TimerInterface; use React\Http\HttpServer as ReactHttpServer; use React\Http\Message\Response; -use React\Http\Message\Uri; use React\Http\Middleware\LimitConcurrentRequestsMiddleware; use React\Http\Middleware\RequestBodyBufferMiddleware; use React\Http\Middleware\RequestBodyParserMiddleware; @@ -68,8 +67,8 @@ public function __construct( protected HttpKernel $kernel, protected LoopInterface $loop, protected HttpFoundationFactory $factory, - protected string $host = '127.0.0.1', - protected int $port = 8099, + public readonly string $host = '127.0.0.1', + public readonly int $port = 8099, ) { } @@ -102,33 +101,23 @@ public function listen(): static return $this; } - /** - * Get the proxy server base URL. - * - * @return string - */ - public function url(): string - { - return "http://{$this->host}:{$this->port}"; - } - /** * Handle the request. * - * @param ServerRequestInterface $psr_request + * @param ServerRequestInterface $request * @return Promise|Response */ - protected function handleRequest(ServerRequestInterface $psr_request): Promise|Response + protected function handleRequest(ServerRequestInterface $request): Promise|Response { - $psr_request = $psr_request->withUri(new Uri($psr_request->getQueryParams()['url'])); + $request = $this->rewriteRequestUri($request); // If this is just a request for a static asset, just stream that content back - if ($static_response = $this->staticResponse($psr_request)) { + if ($static_response = $this->staticResponse($request)) { return $static_response; } $promise = $this->runRequestThroughKernel( - Request::createFromBase($this->factory->createRequest($psr_request)), + Request::createFromBase($this->factory->createRequest($request)), ); // Handle exception @@ -140,6 +129,21 @@ protected function handleRequest(ServerRequestInterface $psr_request): Promise|R return $promise; } + protected function rewriteRequestUri(ServerRequestInterface $request): ServerRequestInterface + { + $uri = $request->getUri(); + + $params = $request->getQueryParams(); + + [$scheme, $host, $port] = json_decode(base64_decode($params['__dusk'])); + + unset($params['__dusk']); + + return $request + ->withUri($uri->withScheme($scheme)->withHost($host)->withPort($port)->withQuery(http_build_query($params))) + ->withQueryParams($params); + } + /** * Pass a dynamic request to the Kernel. * diff --git a/src/Http/UrlGenerator.php b/src/Http/UrlGenerator.php index 1e5664dac..f3c2f225c 100644 --- a/src/Http/UrlGenerator.php +++ b/src/Http/UrlGenerator.php @@ -3,7 +3,10 @@ namespace Laravel\Dusk\Http; use Illuminate\Contracts\Routing\UrlGenerator as UrlGeneratorContract; +use Illuminate\Support\Facades\URL; +use Illuminate\Support\Str; use Illuminate\Support\Traits\ForwardsCalls; +use React\Http\Message\Uri; /** * @method string query(string $path, array $query = [], mixed $extra = [], bool|null $secure = null) @@ -13,7 +16,8 @@ class UrlGenerator implements UrlGeneratorContract use ForwardsCalls; public function __construct( - protected string $endpoint, + protected string $proxyHostname, + protected int $proxyPort, protected string $appHost, protected UrlGeneratorContract $url, ) { @@ -78,11 +82,20 @@ public function proxy(string $url): string { // TODO: Provide a way to register a callback that allows for more complex matching - $host = parse_url($url, PHP_URL_HOST); + $uri = new Uri($url); - return $host === $this->appHost - ? $this->endpoint.'?url='.urlencode($url) - : $url; + if ($uri->getHost() !== $this->appHost) { + return $url; + } + + $data = [$uri->getScheme(), $uri->getHost(), $uri->getPort()]; + $payload = urlencode(base64_encode(json_encode($data))); + $query = $uri->getQuery().($uri->getQuery() ? '&' : '').'__dusk='.urlencode($payload); + + return (string) $uri + ->withHost($this->proxyHostname) + ->withPort($this->proxyPort) + ->withQuery($query); } public function __call(string $name, array $arguments)