Skip to content
Merged
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,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;
Expand Down Expand Up @@ -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;
}

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

$url = $this->url();

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

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

if (app()->bound('url')) {
Expand All @@ -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();
}

/**
Expand Down Expand Up @@ -194,23 +190,73 @@ 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
{
if (! $this->socket instanceof AmpHttpServer) {
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);
}

/**
* Sets the original asset URL.
*/
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, '/');
}

/**
Expand All @@ -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)) {
Expand Down Expand Up @@ -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 {
Expand Down
8 changes: 8 additions & 0 deletions src/ServerManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
93 changes: 93 additions & 0 deletions tests/Browser/Visit/CanonicalUrlTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
<?php

declare(strict_types=1);

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

afterEach(function (): void {
pest()->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');
Comment thread
MrPunyapal marked this conversation as resolved.

$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");
});
14 changes: 9 additions & 5 deletions tests/Browser/Visit/SubdomainTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 => '
<html>
Expand Down Expand Up @@ -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');
Comment thread
MrPunyapal marked this conversation as resolved.

// 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
13 changes: 13 additions & 0 deletions tests/Unit/Configuration/HostConfigurationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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);
});
6 changes: 6 additions & 0 deletions tests/Unit/Drivers/Laravel/LaravelHttpServerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
Loading