diff --git a/src/Configuration.php b/src/Configuration.php index bf3c458f..9da880e1 100644 --- a/src/Configuration.php +++ b/src/Configuration.php @@ -4,9 +4,11 @@ namespace Pest\Browser; +use Pest\Browser\Drivers\LaravelHttpServer; use Pest\Browser\Enums\BrowserType; use Pest\Browser\Enums\ColorScheme; use Pest\Browser\Playwright\Playwright; +use Pest\Browser\ServerManager; /** * @internal @@ -102,6 +104,12 @@ public function withHost(?string $host): self { Playwright::setHost($host); + $http = ServerManager::instance()->http(); + + if ($http instanceof LaravelHttpServer) { + $http->syncCanonicalUrl(); + } + return $this; } diff --git a/src/Drivers/LaravelHttpServer.php b/src/Drivers/LaravelHttpServer.php index 97ae5fdb..4a664295 100644 --- a/src/Drivers/LaravelHttpServer.php +++ b/src/Drivers/LaravelHttpServer.php @@ -7,6 +7,7 @@ use Amp\ByteStream\ReadableResourceStream; use Amp\Http\Cookie\RequestCookie; use Amp\Http\Server\DefaultErrorHandler; +use Amp\Http\Server\Driver\DefaultHttpDriverFactory; use Amp\Http\Server\HttpServer as AmpHttpServer; use Amp\Http\Server\HttpServerStatus; use Amp\Http\Server\Request as AmpRequest; @@ -70,7 +71,7 @@ public function __destruct() } /** - * Rewrite the given URL to match the server's host and port. + * Rewrite the given URL to match the server's canonical host and port. */ public function rewrite(string $url): string { @@ -85,7 +86,7 @@ public function rewrite(string $url): string $path = $parts['path'] ?? '/'; parse_str($parts['query'] ?? '', $queryParameters); - return (string) Uri::of($this->url()) + return (string) Uri::of($this->canonicalUrl()) ->withPath($path) ->withQuery($queryParameters); } @@ -99,7 +100,16 @@ public function start(): void return; } - $this->socket = $server = SocketHttpServer::createForDirectAccess(new NullLogger()); + // Increase the body size limit beyond Amp's 128-KiB + // default to prevent deadlocks when reading large + // JSON payloads in POST, PUT, and PATCH requests. + $this->socket = $server = SocketHttpServer::createForDirectAccess( + logger: $logger = new NullLogger(), + httpDriverFactory: new DefaultHttpDriverFactory( + logger: $logger, + bodySizeLimit: 64 * 1024 * 1024, + ), + ); $server->expose("{$this->host}:{$this->port}"); $server->start( @@ -148,10 +158,6 @@ public function bootstrap(): void { $this->start(); - $url = $this->url(); - - config(['app.url' => $url]); - config(['cors.paths' => ['*']]); if (app()->bound('url')) { @@ -160,10 +166,40 @@ public function bootstrap(): void assert($urlGenerator instanceof UrlGenerator); $this->setOriginalAssetUrl($urlGenerator->asset('')); + $urlGenerator->forceScheme('http'); + } + + $this->syncCanonicalUrl(); + } + + /** + * Re-sync Laravel's `app.url` and the URL generator's origin to the canonical URL, so that `route()`, + * `asset()`, and `config('app.url')` stay consistent with the host the browser is navigating to. + */ + public function syncCanonicalUrl(): void + { + if (! $this->socket instanceof AmpHttpServer) { + return; + } + + // Guard against being called when the Laravel container is not (yet) bootstrapped, + // e.g. from a Pest `beforeAll` hook, or between test files in a parallel worker + // after the previous test's app has been torn down. + if (! app()->bound('config')) { + return; + } + + $url = $this->canonicalUrl(); + + config(['app.url' => $url]); + + if (app()->bound('url')) { + $urlGenerator = app('url'); + + assert($urlGenerator instanceof UrlGenerator); $urlGenerator->useOrigin($url); $urlGenerator->useAssetOrigin($url); - $urlGenerator->forceScheme('http'); } } @@ -194,7 +230,7 @@ public function throwLastThrowableIfNeeded(): void } /** - * Get the public path for the given path. + * Get the URL of the bound socket (always 127.0.0.1:). */ private function url(): string { @@ -205,6 +241,28 @@ private function url(): string return sprintf('http://%s:%d', $this->host, $this->port); } + /** + * Get the canonical host the user expects URLs to use. + * + * Defaults to the bound IP unless the test configured a host with `withHost(...)`. + */ + private function canonicalHost(): string + { + return Playwright::host() ?? $this->host; + } + + /** + * Get the canonical base URL used for generated links and request URIs. + */ + private function canonicalUrl(): string + { + if (! $this->socket instanceof AmpHttpServer) { + throw new ServerNotFoundException('The HTTP server is not running.'); + } + + return sprintf('http://%s:%d', $this->canonicalHost(), $this->port); + } + /** * Sets the original asset URL. */ @@ -224,11 +282,17 @@ private function handleRequest(AmpRequest $request): Response Execution::instance()->tick(); } + // Re-sync per request as a safety net — if the configured host changes + // mid-test, the URL generator and config should reflect it before the + // app handles the request. + $this->syncCanonicalUrl(); + $canonicalUrl = $this->canonicalUrl(); + $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; + $absoluteUrl = mb_rtrim($canonicalUrl, '/').$fullPath; $filepath = public_path($path); if (file_exists($filepath) && ! is_dir($filepath)) { @@ -261,15 +325,13 @@ private function handleRequest(AmpRequest $request): Response $symfonyRequest->headers->add($request->getHeaders()); - // Set the Host header to match the configured host for subdomain routing - $configuredHost = Playwright::host(); - if ($configuredHost !== null) { - $hostHeader = sprintf('%s:%d', $configuredHost, $this->port); - $symfonyRequest->headers->set('Host', $hostHeader); - // Also set SERVER_NAME for Laravel routing - $symfonyRequest->server->set('SERVER_NAME', $configuredHost); - $symfonyRequest->server->set('HTTP_HOST', $hostHeader); - } + // Ensure the framework sees the canonical host (e.g. for subdomain routing) + // even when the browser arrived via a different network host (e.g. 127.0.0.1). + $canonicalHost = $this->canonicalHost(); + $hostHeader = sprintf('%s:%d', $canonicalHost, $this->port); + $symfonyRequest->headers->set('Host', $hostHeader); + $symfonyRequest->server->set('SERVER_NAME', $canonicalHost); + $symfonyRequest->server->set('HTTP_HOST', $hostHeader); $debug = config('app.debug'); diff --git a/tests/Browser/Visit/CanonicalUrlTest.php b/tests/Browser/Visit/CanonicalUrlTest.php new file mode 100644 index 00000000..d3b0ebc1 --- /dev/null +++ b/tests/Browser/Visit/CanonicalUrlTest.php @@ -0,0 +1,66 @@ +url() when withHost is set', function (): void { + Route::domain('app.localhost')->get('/canonical/request-url', fn (Request $request) => $request->url()); + + pest()->browser()->withHost('app.localhost'); + + $port = ServerManager::instance()->http()->port; // @phpstan-ignore-line + + visit('/canonical/request-url') + ->assertUrlIs("http://app.localhost:{$port}/canonical/request-url") + ->assertSee("http://app.localhost:{$port}/canonical/request-url"); +}); + +it('generates route() URLs against the canonical host when withHost is set', function (): void { + Route::domain('app.localhost')->as('canonical.test')->get('/canonical/route-url', fn () => route('canonical.test')); + + pest()->browser()->withHost('app.localhost'); + + $port = ServerManager::instance()->http()->port; // @phpstan-ignore-line + + visit('/canonical/route-url') + ->assertSee("http://app.localhost:{$port}/canonical/route-url"); + + expect(route('canonical.test'))->toBe("http://app.localhost:{$port}/canonical/route-url"); +}); + +it('reverts to the bound IP origin when withHost(null) clears the configured host', function (): void { + Route::as('canonical.no-host')->get('/canonical/no-host', fn () => route('canonical.no-host')); + + // Ensure no leaking host from earlier tests. + pest()->browser()->withHost(null); + + $port = ServerManager::instance()->http()->port; // @phpstan-ignore-line + + visit('/canonical/no-host') + ->assertSee("http://127.0.0.1:{$port}/canonical/no-host"); + + expect(route('canonical.no-host'))->toBe("http://127.0.0.1:{$port}/canonical/no-host"); +}); + +it('updates the URL generator immediately when withHost changes mid-test', function (): void { + Route::domain('first.localhost')->as('canonical.first')->get('/canonical/first', fn () => route('canonical.first')); + Route::domain('second.localhost')->as('canonical.second')->get('/canonical/second', fn () => route('canonical.second')); + + $port = ServerManager::instance()->http()->port; // @phpstan-ignore-line + + visit('/canonical/first') + ->withHost('first.localhost') + ->assertSee("http://first.localhost:{$port}/canonical/first"); + + expect(route('canonical.first'))->toBe("http://first.localhost:{$port}/canonical/first"); + + // Per-request resync should also surface the new host on the page itself. + visit('/canonical/second') + ->withHost('second.localhost') + ->assertSee("http://second.localhost:{$port}/canonical/second"); + + expect(route('canonical.second'))->toBe("http://second.localhost:{$port}/canonical/second"); +}); diff --git a/tests/Browser/Visit/LargeBodyTest.php b/tests/Browser/Visit/LargeBodyTest.php new file mode 100644 index 00000000..10d67292 --- /dev/null +++ b/tests/Browser/Visit/LargeBodyTest.php @@ -0,0 +1,47 @@ + [ + 'received_bytes' => mb_strlen($request->getContent()), + 'received_count' => count($request->json('items', [])), + ]); + + // Build a payload that comfortably exceeds Amp's 128 KiB default. + $items = []; + for ($i = 0; $i < 5000; $i++) { + $items[] = [ + 'id' => $i, + 'name' => str_repeat('x', 32), + 'description' => str_repeat('y', 64), + ]; + } + $payload = json_encode(['items' => $items]); + + expect(mb_strlen($payload))->toBeGreaterThan(128 * 1024); + + // Render a tiny page that POSTs the payload via fetch and writes the JSON + // response into the DOM so we can assert against it. + Route::get('/large-body/post-from-page', fn () => ' + +

+            
+        
+    ');
+
+    visit('/large-body/post-from-page')
+        ->assertSee('"received_bytes":'.mb_strlen($payload))
+        ->assertSee('"received_count":5000');
+});
diff --git a/tests/Browser/Visit/SubdomainTest.php b/tests/Browser/Visit/SubdomainTest.php
index bfb33678..192359ac 100644
--- a/tests/Browser/Visit/SubdomainTest.php
+++ b/tests/Browser/Visit/SubdomainTest.php
@@ -70,18 +70,18 @@
         'host' => request()->getHost(),
     ]);
 
-    // Set global host: test.domain
-    pest()->browser()->withHost('test.domain');
+    // Set global host: test.localhost
+    pest()->browser()->withHost('test.localhost');
 
     // 1. Visit withHost: api.localhost
     visit('/api/health')
         ->withHost('api.localhost')
         ->assertSee('"host":"api.localhost"')
-        ->assertDontSee('test.domain');
+        ->assertDontSee('test.localhost');
 
-    // 2. Visit without withHost: should use global host "test.domain"
+    // 2. Visit without withHost: should use global host "test.localhost"
     visit('/')
-        ->assertSee('"host":"test.domain"')
+        ->assertSee('"host":"test.localhost"')
         ->assertDontSee('api.localhost');
 });