Skip to content
20 changes: 18 additions & 2 deletions src/Api/Concerns/MakesElementAssertions.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@

namespace Pest\Browser\Api\Concerns;

use Illuminate\Support\Str;
use Pest\Browser\Api\Webpage;
use PHPUnit\Framework\ExpectationFailedException;

Expand Down Expand Up @@ -155,7 +154,7 @@ public function assertCount(string $selector, int $expected): Webpage
*/
public function assertScript(string $expression, mixed $expected = true): Webpage
{
if (! Str::contains($expression, ['===', '!==', '==', '!=', '>', '<', '>=', '<=', '&&', '||']) && ! Str::startsWith($expression, 'return ') && ! Str::startsWith($expression, 'function')) {
if (! self::strContainsAny($expression, ['===', '!==', '==', '!=', '>', '<', '>=', '<=', '&&', '||']) && ! str_starts_with($expression, 'return ') && ! str_starts_with($expression, 'function')) {
$expression = "function() { return {$expression}; }";
}

Expand Down Expand Up @@ -593,4 +592,21 @@ public function waitForText(string|int|float $text): Webpage

return $this->assertSee($text);
}

/**
* Return true if haystack contains any of the given needles
Copy link

Copilot AI Jan 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The documentation comment is missing a period at the end of the sentence, which is inconsistent with other documentation comments in the codebase (e.g., line 599 "Sets the host for the server.").

Suggested change
* Return true if haystack contains any of the given needles
* Return true if haystack contains any of the given needles.

Copilot uses AI. Check for mistakes.
*
* @param string $haystack String to look in
* @param array<int, string> $needles List of needles to look for in haystack
*/
private static function strContainsAny(string $haystack, array $needles): bool
{
foreach ($needles as $needle) {
if (str_contains($haystack, $needle)) {
return true;
}
}

return false;
}
}
61 changes: 59 additions & 2 deletions src/Api/PendingAwaitablePage.php
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,17 @@ public function withUserAgent(string $userAgent): self
]);
}

/**
* Sets the host for the server.
*/
public function withHost(string $host): self
{
return new self($this->browserType, $this->device, $this->url, [
'host' => $host,
...$this->options,
]);
}

/**
* Sets the timezone for the page.
*/
Expand Down Expand Up @@ -147,6 +158,17 @@ public function geolocation(float $latitude, float $longitude): self
* Creates the webpage instance.
*/
private function createAwaitablePage(): AwaitableWebpage
{
$options = $this->options;
$host = $this->extractHost($options);

return $this->withTemporaryHost($host, fn (): AwaitableWebpage => $this->buildAwaitablePage($options));
}

/**
* @param array<string, mixed> $options
*/
private function buildAwaitablePage(array $options): AwaitableWebpage
{
$browser = Playwright::browser($this->browserType)->launch();

Expand All @@ -155,16 +177,51 @@ private function createAwaitablePage(): AwaitableWebpage
'timezoneId' => 'UTC',
'colorScheme' => Playwright::defaultColorScheme()->value,
...$this->device->context(),
...$this->options,
...$options,
]);

$context->addInitScript(InitScript::get());

$url = ComputeUrl::from($this->url);

return new AwaitableWebpage(
$context->newPage()->goto($url, $this->options),
$context->newPage()->goto($url, $options),
$url,
);
}

/**
* @param array<string, mixed> &$options
*/
private function extractHost(array &$options): ?string
{
if (! array_key_exists('host', $options)) {
return null;
}

$host = $options['host'];

unset($options['host']);

return is_string($host) ? $host : null;
}

/**
* @param callable(): AwaitableWebpage $callback
*/
private function withTemporaryHost(?string $host, callable $callback): AwaitableWebpage
{
if ($host === null) {
return $callback();
}

$previousHost = Playwright::host();
Playwright::setHost($host);

try {
return $callback();
} finally {
Playwright::setHost($previousHost);
}
}
}
2 changes: 1 addition & 1 deletion src/Configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ public function userAgent(string $userAgent): self
/**
* Sets the host for the server.
*/
public function withHost(string $host): self
public function withHost(?string $host): self
{
Playwright::setHost($host);

Expand Down
10 changes: 9 additions & 1 deletion tests/ArchTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,12 @@

declare(strict_types=1);

return;
arch()
->expect(['Illuminate', 'Laravel', 'Livewire'])
->toOnlyBeUsedIn([
Pest\Browser\Api\Livewire::class,
Pest\Browser\Api\TestableLivewire::class,
Pest\Browser\Cleanables\Livewire::class,
Pest\Browser\Drivers\LaravelHttpServer::class,
'Workbench',
]);
139 changes: 139 additions & 0 deletions tests/Browser/Visit/SubdomainTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
declare(strict_types=1);

use Illuminate\Support\Facades\Route;
use Pest\Browser\Playwright\Playwright;

it('can visit non-subdomain routes with subdomain host browser testing', function (): void {
Route::get('/app-test', fn (): string => '
Expand Down Expand Up @@ -40,3 +41,141 @@
->assertSee('"subdomain":"api"')
->assertSee('"host":"api.localhost"');
});

it('Can chain withHost on visit', function (): void {
Copy link

Copilot AI Jan 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test description should start with a lowercase letter to be consistent with the existing test naming convention in this file (e.g., "can visit non-subdomain routes" on line 8, "works with Laravel subdomain style" on line 27).

Copilot uses AI. Check for mistakes.
Route::domain('{subdomain}.localhost')->group(function (): void {
Route::get('/api/health', fn (): array => [
'status' => 'ok',
'subdomain' => request()->route('subdomain'),
'host' => request()->getHost(),
]);
});

visit('/api/health')
->withHost('api.localhost')
->assertSee('"status":"ok"')
->assertSee('"subdomain":"api"')
->assertSee('"host":"api.localhost"');
});

it('Chaining withHost will not override global host', function (): void {
Copy link

Copilot AI Jan 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test description should start with a lowercase letter to be consistent with the existing test naming convention in this file (e.g., "can visit non-subdomain routes" on line 8, "works with Laravel subdomain style" on line 27).

Suggested change
it('Chaining withHost will not override global host', function (): void {
it('chaining withHost will not override global host', function (): void {

Copilot uses AI. Check for mistakes.
Route::domain('{subdomain}.localhost')->group(function (): void {
Route::get('/api/health', fn (): array => [
'subdomain' => request()->route('subdomain'),
'host' => request()->getHost(),
]);
});

Route::get('/', fn (): array => [
'host' => request()->getHost(),
]);

// Set global host: test.domain
pest()->browser()->withHost('test.domain');

// 1. Visit withHost: api.localhost
visit('/api/health')
->withHost('api.localhost')
->assertSee('"host":"api.localhost"')
->assertDontSee('test.domain');

// 2. Visit without withHost: should use global host "test.domain"
visit('/')
->assertSee('"host":"test.domain"')
->assertDontSee('api.localhost');
});

it('uses the first withHost when chained multiple times', function (): void {
// Because of the spread operator "...$this->options" in PendingAwaitablePage
Route::domain('{subdomain}.localhost')->group(function (): void {
Route::get('/api/info', fn (): array => [
'subdomain' => request()->route('subdomain'),
'host' => request()->getHost(),
]);
});

visit('/api/info')
->withHost('first.localhost')
->withHost('api.localhost')
->assertSee('"host":"first.localhost"')
->assertSee('"subdomain":"first"')
->assertDontSee('api.localhost');
});

it('withHost works correctly when combined with other options', function (): void {
Route::domain('{subdomain}.localhost')->group(function (): void {
Route::get('/api/locale', fn (): array => [
'host' => request()->getHost(),
'subdomain' => request()->route('subdomain'),
]);
});

visit('/api/locale')
->withHost('api.localhost')
->inDarkMode()
->assertSee('"host":"api.localhost"')
->assertSee('"subdomain":"api"');
});

it('correctly alternates hosts across multiple visits', function (): void {
Route::domain('{subdomain}.localhost')->group(function (): void {
Route::get('/check', fn (): array => [
'host' => request()->getHost(),
'subdomain' => request()->route('subdomain'),
]);
});

// Visit 1: api.localhost
visit('/check')
->withHost('api.localhost')
->assertSee('"host":"api.localhost"')
->assertSee('"subdomain":"api"');

// Visit 2: admin.localhost
visit('/check')
->withHost('admin.localhost')
->assertSee('"host":"admin.localhost"')
->assertSee('"subdomain":"admin"');

// Visit 3: back to api.localhost
visit('/check')
->withHost('api.localhost')
->assertSee('"host":"api.localhost"')
->assertSee('"subdomain":"api"');
});

it('withHost works when no global host is configured', function (): void {
Route::domain('{subdomain}.localhost')->group(function (): void {
Route::get('/standalone', fn (): array => [
'host' => request()->getHost(),
'subdomain' => request()->route('subdomain'),
]);
});

// No pest()->browser()->withHost() call - just use per-visit withHost
visit('/standalone')
->withHost('custom.localhost')
->assertSee('"host":"custom.localhost"')
->assertSee('"subdomain":"custom"');
});

it('restores global host even when page creation encounters issues', function (): void {
Route::get('/restore-check', fn (): array => [
'host' => request()->getHost(),
]);

$originalHost = 'original.localhost';
pest()->browser()->withHost($originalHost);

// Perform a visit with a different host
visit('/restore-check')
->withHost('temporary.localhost')
->assertSee('"host":"temporary.localhost"');

// Verify global host is still the original after the visit
expect(Playwright::host())->toBe($originalHost);

// Verify next visit without withHost uses the global host
visit('/restore-check')
->assertSee('"host":"original.localhost"');
});
Loading