diff --git a/src/Api/Concerns/MakesElementAssertions.php b/src/Api/Concerns/MakesElementAssertions.php index c95b9a65..4b466c53 100644 --- a/src/Api/Concerns/MakesElementAssertions.php +++ b/src/Api/Concerns/MakesElementAssertions.php @@ -4,7 +4,6 @@ namespace Pest\Browser\Api\Concerns; -use Illuminate\Support\Str; use Pest\Browser\Api\Webpage; use PHPUnit\Framework\ExpectationFailedException; @@ -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}; }"; } @@ -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 + * + * @param string $haystack String to look in + * @param array $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; + } } diff --git a/src/Api/PendingAwaitablePage.php b/src/Api/PendingAwaitablePage.php index f8aad8d9..83ad7b77 100644 --- a/src/Api/PendingAwaitablePage.php +++ b/src/Api/PendingAwaitablePage.php @@ -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. */ @@ -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 $options + */ + private function buildAwaitablePage(array $options): AwaitableWebpage { $browser = Playwright::browser($this->browserType)->launch(); @@ -155,7 +177,7 @@ private function createAwaitablePage(): AwaitableWebpage 'timezoneId' => 'UTC', 'colorScheme' => Playwright::defaultColorScheme()->value, ...$this->device->context(), - ...$this->options, + ...$options, ]); $context->addInitScript(InitScript::get()); @@ -163,8 +185,43 @@ private function createAwaitablePage(): AwaitableWebpage $url = ComputeUrl::from($this->url); return new AwaitableWebpage( - $context->newPage()->goto($url, $this->options), + $context->newPage()->goto($url, $options), $url, ); } + + /** + * @param array &$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); + } + } } diff --git a/src/Configuration.php b/src/Configuration.php index 0c2639f7..bf3c458f 100644 --- a/src/Configuration.php +++ b/src/Configuration.php @@ -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); diff --git a/tests/ArchTest.php b/tests/ArchTest.php index d37896ef..2f5fe285 100644 --- a/tests/ArchTest.php +++ b/tests/ArchTest.php @@ -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', + ]); diff --git a/tests/Browser/Visit/SubdomainTest.php b/tests/Browser/Visit/SubdomainTest.php index 702157b6..bfb33678 100644 --- a/tests/Browser/Visit/SubdomainTest.php +++ b/tests/Browser/Visit/SubdomainTest.php @@ -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 => ' @@ -40,3 +41,141 @@ ->assertSee('"subdomain":"api"') ->assertSee('"host":"api.localhost"'); }); + +it('Can chain withHost on visit', function (): void { + 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 { + 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"'); +});