diff --git a/src/Configuration.php b/src/Configuration.php index bf3c458f..5c6b1fd5 100644 --- a/src/Configuration.php +++ b/src/Configuration.php @@ -4,6 +4,7 @@ namespace Pest\Browser; +use Pest\Browser\Drivers\LaravelHttpServer; use Pest\Browser\Enums\BrowserType; use Pest\Browser\Enums\ColorScheme; use Pest\Browser\Playwright\Playwright; @@ -102,6 +103,13 @@ public function withHost(?string $host): self { Playwright::setHost($host); + // If the server is already running, re-point generated URLs at the new host straight away + $serverManager = ServerManager::instance(); + + if ($serverManager->hasHttp() && ($http = $serverManager->http()) instanceof LaravelHttpServer) { + $http->syncGeneratedUrls(); + } + return $this; } diff --git a/src/Drivers/LaravelHttpServer.php b/src/Drivers/LaravelHttpServer.php index ffa12a6c..e7f296ec 100644 --- a/src/Drivers/LaravelHttpServer.php +++ b/src/Drivers/LaravelHttpServer.php @@ -24,6 +24,7 @@ use Pest\Browser\Execution; use Pest\Browser\GlobalState; use Pest\Browser\Playwright\Playwright; +use Psr\Http\Message\UriInterface; use Psr\Log\NullLogger; use Symfony\Component\Mime\MimeTypes; use Throwable; @@ -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 { @@ -148,10 +149,6 @@ public function bootstrap(): void { $this->start(); - $url = $this->url(); - - config(['app.url' => $url]); - config(['cors.paths' => ['*']]); if (app()->bound('url')) { @@ -160,11 +157,10 @@ public function bootstrap(): void assert($urlGenerator instanceof UrlGenerator); $this->setOriginalAssetUrl($urlGenerator->asset('')); - - $urlGenerator->useOrigin($url); - $urlGenerator->useAssetOrigin($url); $urlGenerator->forceScheme('http'); } + + $this->syncGeneratedUrls(); } /** @@ -194,7 +190,42 @@ public function throwLastThrowableIfNeeded(): void } /** - * Get the public path for the given path. + * Point Laravel's `app.url` and the URL generator's origin at the given URL, so that `route()`, + * `asset()` and `config('app.url')` stay consistent with the host the browser is navigating to. + */ + public function syncGeneratedUrls(?string $url = null): 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->url(); + + config(['app.url' => $url]); + + if (app()->bound('url')) { + $urlGenerator = app('url'); + + assert($urlGenerator instanceof UrlGenerator); + + $urlGenerator->setRequest(Request::create($url)); + $urlGenerator->useOrigin($url); + $urlGenerator->useAssetOrigin($url); + } + } + + /** + * Get the base URL of the server. + * + * Uses the host configured with `withHost(...)` when set, so generated links + * match the host the browser navigates to, and falls back to the bound IP. */ private function url(): string { @@ -202,7 +233,19 @@ private function url(): string throw new ServerNotFoundException('The HTTP server is not running.'); } - return sprintf('http://%s:%d', $this->host, $this->port); + return sprintf('http://%s:%d', Playwright::host() ?? $this->host, $this->port); + } + + /** + * Get the origin (scheme, host and port) the given request URI arrived on. + */ + private function originOf(UriInterface $uri): string + { + if ($uri->getHost() === '') { + return mb_rtrim($this->url(), '/'); + } + + return sprintf('http://%s:%d', $uri->getHost(), $uri->getPort() ?? $this->port); } /** @@ -210,7 +253,10 @@ private function url(): string */ private function setOriginalAssetUrl(string $url): void { - $this->originalAssetUrl = mb_rtrim($url, '/'); + // Captured once, before any sync re-points the generator's asset origin at this + // server. Re-capturing later would record our own origin as the "original" and + // stop asset rewriting from matching anything. + $this->originalAssetUrl ??= mb_rtrim($url, '/'); } /** @@ -228,7 +274,17 @@ private function handleRequest(AmpRequest $request): Response $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; + + // The browser reaches us on the host the test asked for, so trust the + // request's own origin rather than the globally configured one. This + // keeps a per-visit `withHost(...)` intact for the page's later + // fetch/XHR requests, after the global host has been restored. + $origin = $this->originOf($uri); + $absoluteUrl = $origin.$fullPath; + + // Keep `route()`, `asset()` and `config('app.url')` aligned with the + // origin the app is answering on for this request. + $this->syncGeneratedUrls($origin); $filepath = public_path($path); if (file_exists($filepath) && ! is_dir($filepath)) { @@ -261,16 +317,6 @@ 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); - } - $debug = config('app.debug'); try { diff --git a/src/ServerManager.php b/src/ServerManager.php index 4e406fd9..6d1af5fb 100644 --- a/src/ServerManager.php +++ b/src/ServerManager.php @@ -78,6 +78,14 @@ public function playwright(): PlaywrightServer return $this->playwright; } + /** + * Determines whether the HTTP server instance has already been created. + */ + public function hasHttp(): bool + { + return $this->http instanceof HttpServer; + } + /** * Returns the HTTP server process instance. */ diff --git a/tests/Browser/Visit/CanonicalUrlTest.php b/tests/Browser/Visit/CanonicalUrlTest.php new file mode 100644 index 00000000..2ad7316c --- /dev/null +++ b/tests/Browser/Visit/CanonicalUrlTest.php @@ -0,0 +1,93 @@ +browser()->withHost(null); +}); + +it('exposes the canonical host on request()->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 (): string => 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 (): string => route('canonical.no-host')); + + $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 (): string => route('canonical.first')); + Route::domain('second.localhost')->as('canonical.second')->get('/canonical/second', fn (): string => 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"); +}); + +it('rewrites asset URLs to the configured host so scripts stay same-origin', function (): void { + @file_put_contents(public_path('canonical-asset.js'), "console.log('base http://localhost');"); + + pest()->browser()->withHost('assets.localhost'); + + visit('/canonical-asset.js') + ->assertSee('http://assets.localhost') + ->assertDontSee('http://127.0.0.1'); +}); + +it('updates generated URLs as soon as withHost changes, before the next request', function (): void { + Route::as('canonical.plain')->get('/canonical/plain', fn (): string => 'plain'); + + $port = ServerManager::instance()->http()->port; // @phpstan-ignore-line + + pest()->browser()->withHost('one.localhost'); + visit('/canonical/plain')->assertSee('plain'); + + expect(route('canonical.plain'))->toBe("http://one.localhost:{$port}/canonical/plain"); + + // No request in between: the URL generator should already reflect the new host. + pest()->browser()->withHost('two.localhost'); + + expect(route('canonical.plain'))->toBe("http://two.localhost:{$port}/canonical/plain"); +}); diff --git a/tests/Browser/Visit/SubdomainTest.php b/tests/Browser/Visit/SubdomainTest.php index bfb33678..f2a5ad3b 100644 --- a/tests/Browser/Visit/SubdomainTest.php +++ b/tests/Browser/Visit/SubdomainTest.php @@ -5,6 +5,10 @@ use Illuminate\Support\Facades\Route; use Pest\Browser\Playwright\Playwright; +afterEach(function (): void { + pest()->browser()->withHost(null); +}); + it('can visit non-subdomain routes with subdomain host browser testing', function (): void { Route::get('/app-test', fn (): string => ' @@ -70,18 +74,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'); }); diff --git a/tests/Unit/Configuration/HostConfigurationTest.php b/tests/Unit/Configuration/HostConfigurationTest.php index a6797ad6..0dea35eb 100644 --- a/tests/Unit/Configuration/HostConfigurationTest.php +++ b/tests/Unit/Configuration/HostConfigurationTest.php @@ -4,12 +4,17 @@ use Pest\Browser\Configuration; use Pest\Browser\Playwright\Playwright; +use Pest\Browser\ServerManager; beforeEach(function (): void { // Reset Playwright state before each test Playwright::setHost(null); }); +afterEach(function (): void { + Playwright::setHost(null); +}); + it('can set host via configuration', function (): void { $config = new Configuration(); @@ -64,3 +69,11 @@ expect(Playwright::host())->toBe($host); } }); + +it('does not create the http server when setting a host', function (): void { + $hadServer = ServerManager::instance()->hasHttp(); + + new Configuration()->withHost('tenant.localhost'); + + expect(ServerManager::instance()->hasHttp())->toBe($hadServer); +}); diff --git a/tests/Unit/Drivers/Laravel/LaravelHttpServerTest.php b/tests/Unit/Drivers/Laravel/LaravelHttpServerTest.php index 7ca9688e..e3fa739e 100644 --- a/tests/Unit/Drivers/Laravel/LaravelHttpServerTest.php +++ b/tests/Unit/Drivers/Laravel/LaravelHttpServerTest.php @@ -7,6 +7,12 @@ use function Pest\Laravel\withServerVariables; use function Pest\Laravel\withUnencryptedCookie; +// These assertions pin the bound IP, so make the host explicit rather than +// inheriting whatever an earlier test file configured globally. +beforeEach(function (): void { + pest()->browser()->withHost(null); +}); + it('rewrites the URLs on JS files', function (): void { @file_put_contents( public_path('app.js'),