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..ebf57a67b 100644 --- a/src/Browser.php +++ b/src/Browser.php @@ -10,6 +10,9 @@ use Facebook\WebDriver\WebDriverPoint; use Illuminate\Support\Str; use Illuminate\Support\Traits\Macroable; +use Laravel\Dusk\Http\ProxyServer; +use Laravel\Dusk\Http\UrlGenerator; +use React\EventLoop\Loop; class Browser { @@ -182,6 +185,9 @@ public function visit($url) $url = static::$baseUrl.'/'.ltrim($url, '/'); } + // Pass the request through our proxy + $url = app(UrlGenerator::class)->proxy($url); + $this->driver->navigate()->to($url); // If the page variable was set, we will call the "on" method which will set a @@ -675,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/Concerns/ProvidesProxyServer.php b/src/Concerns/ProvidesProxyServer.php new file mode 100644 index 000000000..d52db3b84 --- /dev/null +++ b/src/Concerns/ProvidesProxyServer.php @@ -0,0 +1,27 @@ +afterApplicationCreated(function () { + $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(); + }); + } +} diff --git a/src/Driver/AsyncCommandExecutor.php b/src/Driver/AsyncCommandExecutor.php new file mode 100644 index 000000000..f2f64681f --- /dev/null +++ b/src/Driver/AsyncCommandExecutor.php @@ -0,0 +1,190 @@ +withRejectErrorResponse(false); + + [$url, $method, $headers, $payload] = $this->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; + }); + + $request->catch(function (Throwable $exception) use (&$resolved) { + if ($resolved instanceof ResponseException) { + $resolved = $exception->getResponse(); + } else { + throw $exception; + } + }); + + 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..939e0ad97 100644 --- a/src/DuskServiceProvider.php +++ b/src/DuskServiceProvider.php @@ -2,11 +2,43 @@ 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; 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(...)), + ); + }); + + $this->app->singleton(UrlGenerator::class, function (Application $app) { + $proxy = $app->make(ProxyServer::class); + + return new UrlGenerator( + proxyHostname: $proxy->host, + proxyPort: $proxy->port, + appHost: parse_url(config('app.url'), PHP_URL_HOST), + url: $app->make(UrlGeneratorContract::class), + ); + }); + } + /** * Bootstrap any package services. * @@ -50,4 +82,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..19d0c0b3a --- /dev/null +++ b/src/Http/ProxyServer.php @@ -0,0 +1,286 @@ +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; + } + + /** + * Handle the request. + * + * @param ServerRequestInterface $request + * @return Promise|Response + */ + protected function handleRequest(ServerRequestInterface $request): Promise|Response + { + $request = $this->rewriteRequestUri($request); + + // If this is just a request for a static asset, just stream that content back + if ($static_response = $this->staticResponse($request)) { + return $static_response; + } + + $promise = $this->runRequestThroughKernel( + Request::createFromBase($this->factory->createRequest($request)), + ); + + // Handle exception + $promise->catch(function (Throwable $exception) { + return Response::plaintext($exception->getMessage()."\n".$exception->getTraceAsString()) + ->withStatus(Response::STATUS_INTERNAL_SERVER_ERROR); + }); + + 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. + * + * @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/Http/UrlGenerator.php b/src/Http/UrlGenerator.php new file mode 100644 index 000000000..f3c2f225c --- /dev/null +++ b/src/Http/UrlGenerator.php @@ -0,0 +1,111 @@ +__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 + + $uri = new Uri($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) + { + $result = $this->forwardDecoratedCallTo($this->url, $name, $arguments); + + if (is_string($result) && filter_var($result, FILTER_VALIDATE_URL)) { + return $this->proxy($result); + } + + return $result; + } +} 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) ); }