Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
100 changes: 81 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,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');
}
}

Expand Down Expand Up @@ -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:<port>).
*/
private function url(): string
{
Expand All @@ -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.
*/
Expand All @@ -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)) {
Expand Down Expand Up @@ -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');

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