Skip to content
8 changes: 8 additions & 0 deletions src/Configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
}

Expand Down
101 changes: 82 additions & 19 deletions src/Drivers/LaravelHttpServer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
{
Expand All @@ -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);
}
Expand All @@ -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(
Expand Down Expand Up @@ -148,10 +158,6 @@ public function bootstrap(): void
{
$this->start();

$url = $this->url();

config(['app.url' => $url]);

config(['cors.paths' => ['*']]);

if (app()->bound('url')) {
Expand All @@ -160,10 +166,41 @@ 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->setRequest(Request::create($url));
$urlGenerator->useOrigin($url);
$urlGenerator->useAssetOrigin($url);
$urlGenerator->forceScheme('http');
}
}

Expand Down Expand Up @@ -194,7 +231,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:<port>).
*/
private function url(): string
{
Expand All @@ -205,6 +242,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.
*/
Expand All @@ -224,11 +283,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)) {
Expand Down Expand Up @@ -261,15 +326,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');

Expand Down
66 changes: 66 additions & 0 deletions tests/Browser/Visit/CanonicalUrlTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
<?php

declare(strict_types=1);

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use Pest\Browser\ServerManager;

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 () => 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");
});
47 changes: 47 additions & 0 deletions tests/Browser/Visit/LargeBodyTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php

declare(strict_types=1);

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;

it('accepts JSON request bodies larger than the default 128 KiB limit', function (): void {
Route::post('/large-body/echo', fn (Request $request): array => [
'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 () => '
<html><body>
<pre id="result"></pre>
<script>
fetch("/large-body/echo", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: '.json_encode($payload).',
})
.then(r => r.json())
.then(data => { document.getElementById("result").textContent = JSON.stringify(data); });
</script>
</body></html>
');

visit('/large-body/post-from-page')
->assertSee('"received_bytes":'.mb_strlen($payload))
->assertSee('"received_count":5000');
});
10 changes: 5 additions & 5 deletions tests/Browser/Visit/SubdomainTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});

Expand Down