diff --git a/src/Configuration.php b/src/Configuration.php index bf3c458f..dfebcfb5 100644 --- a/src/Configuration.php +++ b/src/Configuration.php @@ -4,6 +4,7 @@ namespace Pest\Browser; +use InvalidArgumentException; use Pest\Browser\Enums\BrowserType; use Pest\Browser\Enums\ColorScheme; use Pest\Browser\Playwright\Playwright; @@ -45,6 +46,43 @@ public function inSafari(): self return $this; } + /** + * Uses a system-installed branded Chromium browser via its Playwright channel. + * + * Supported channels are "chrome", "chrome-beta", "chrome-dev", + * "chrome-canary", "msedge", "msedge-beta", "msedge-dev" and + * "msedge-canary". + * + * Channels are only supported for Chromium-family browsers (Google Chrome + * and Microsoft Edge) and cannot be combined with usingExecutablePath(). + */ + public function usingChannel(string $channel): self + { + $this->ensureExecutablePathIsNotSet(); + + Playwright::setChannel($channel); + + return $this; + } + + /** + * Uses the browser executable at the given path. + * + * Note that Playwright only supports Chromium-family binaries via an + * executable path. Branded Firefox and Safari builds are not supported, + * since Playwright relies on patched builds of those browsers. + * + * This cannot be combined with usingChannel(). + */ + public function usingExecutablePath(string $executablePath): self + { + $this->ensureChannelIsNotSet(); + + Playwright::setExecutablePath($executablePath); + + return $this; + } + /** * Sets the theme to light mode. */ @@ -124,4 +162,24 @@ public function diff(): self return $this; } + + /** + * Ensures that no channel has been configured yet. + */ + private function ensureChannelIsNotSet(): void + { + if (Playwright::channel() !== null) { + throw new InvalidArgumentException('The "channel" and "executablePath" options are mutually exclusive.'); + } + } + + /** + * Ensures that no executable path has been configured yet. + */ + private function ensureExecutablePathIsNotSet(): void + { + if (Playwright::executablePath() !== null) { + throw new InvalidArgumentException('The "channel" and "executablePath" options are mutually exclusive.'); + } + } } diff --git a/src/Playwright/Client.php b/src/Playwright/Client.php index 876e7af3..55066d48 100644 --- a/src/Playwright/Client.php +++ b/src/Playwright/Client.php @@ -43,6 +43,35 @@ public static function instance(): self return self::$instance; } + /** + * The launch options forwarded to the Playwright server. + * + * @return array + */ + public static function launchOptions(): array + { + return array_filter([ + 'headless' => Playwright::isHeadless(), + 'ignoreHTTPSErrors' => true, + 'bypassCSP' => true, + 'channel' => Playwright::channel(), + 'executablePath' => Playwright::executablePath(), + ], fn (mixed $value): bool => $value !== null); + } + + /** + * Builds the connection query string for the given browser and launch options. + * + * @param array $launchOptions + */ + public static function connectionQuery(string $browser, array $launchOptions): string + { + return http_build_query([ + 'browser' => $browser, + 'launch-options' => json_encode($launchOptions), + ]); + } + /** * Connects to the Playwright server. */ @@ -51,14 +80,8 @@ public function connectTo(string $url): void if (! $this->websocketConnection instanceof WebsocketConnection) { $browser = Playwright::defaultBrowserType()->toPlaywrightName(); - $launchOptions = json_encode([ - 'headless' => Playwright::isHeadless(), - 'ignoreHTTPSErrors' => true, - 'bypassCSP' => true, - ]); - $this->websocketConnection = connect( - "ws://$url?browser=$browser&launch-options=$launchOptions", + 'ws://'.$url.'?'.self::connectionQuery($browser, self::launchOptions()), ); } } diff --git a/src/Playwright/Playwright.php b/src/Playwright/Playwright.php index c0cffae1..293bddf0 100644 --- a/src/Playwright/Playwright.php +++ b/src/Playwright/Playwright.php @@ -59,6 +59,16 @@ final class Playwright */ private static ?string $host = null; + /** + * The browser channel to use, e.g. "chrome" or "msedge". + */ + private static ?string $channel = null; + + /** + * The path to a browser executable to use. + */ + private static ?string $executablePath = null; + /** * Get a browser factory for the given browser type. */ @@ -155,6 +165,38 @@ public static function host(): ?string return self::$host; } + /** + * Set the browser channel to use. + */ + public static function setChannel(?string $channel): void + { + self::$channel = $channel; + } + + /** + * Get the browser channel to use. + */ + public static function channel(): ?string + { + return self::$channel; + } + + /** + * Set the browser executable path to use. + */ + public static function setExecutablePath(?string $executablePath): void + { + self::$executablePath = $executablePath; + } + + /** + * Get the browser executable path to use. + */ + public static function executablePath(): ?string + { + return self::$executablePath; + } + /** * Get the default color scheme. */ diff --git a/src/ServerManager.php b/src/ServerManager.php index 4e406fd9..cfb05ac0 100644 --- a/src/ServerManager.php +++ b/src/ServerManager.php @@ -62,9 +62,15 @@ public function playwright(): PlaywrightServer $port = Port::find(); $host = Playwright::host() ?? self::DEFAULT_HOST; + $command = '.'.DIRECTORY_SEPARATOR.'node_modules'.DIRECTORY_SEPARATOR.'.bin'.DIRECTORY_SEPARATOR.'playwright run-server --host %s --port %d --mode launchServer'; + + if (Playwright::executablePath() !== null) { + $command .= ' --unsafe'; + } + $this->playwright ??= PlaywrightNpmServer::create( PackageJsonDirectory::find(), - '.'.DIRECTORY_SEPARATOR.'node_modules'.DIRECTORY_SEPARATOR.'.bin'.DIRECTORY_SEPARATOR.'playwright run-server --host %s --port %d --mode launchServer', + $command, $host, $port, 'Listening on', diff --git a/tests/Unit/Configuration/SystemBrowserConfigurationTest.php b/tests/Unit/Configuration/SystemBrowserConfigurationTest.php new file mode 100644 index 00000000..f314de79 --- /dev/null +++ b/tests/Unit/Configuration/SystemBrowserConfigurationTest.php @@ -0,0 +1,90 @@ +usingChannel('chrome'); + + expect($result)->toBeInstanceOf(Configuration::class); + expect(Playwright::channel())->toBe('chrome'); +}); + +it('can set a browser executable path via configuration', function (): void { + $config = new Configuration(); + + $result = $config->usingExecutablePath('/usr/bin/google-chrome'); + + expect($result)->toBeInstanceOf(Configuration::class); + expect(Playwright::executablePath())->toBe('/usr/bin/google-chrome'); +}); + +it('defaults channel and executable path to null', function (): void { + expect(Playwright::channel())->toBeNull(); + expect(Playwright::executablePath())->toBeNull(); +}); + +it('supports fluent chaining with other configuration options', function (): void { + $config = new Configuration(); + + $result = $config + ->usingChannel('msedge') + ->headed() + ->timeout(10000); + + expect($result)->toBeInstanceOf(Configuration::class); + expect(Playwright::channel())->toBe('msedge'); +}); + +it('supports fluent chaining of an executable path with other options', function (): void { + $config = new Configuration(); + + $result = $config + ->usingExecutablePath('/usr/bin/google-chrome') + ->headed() + ->timeout(10000); + + expect($result)->toBeInstanceOf(Configuration::class); + expect(Playwright::executablePath())->toBe('/usr/bin/google-chrome'); +}); + +it('throws when combining a channel with an executable path', function (): void { + $config = new Configuration(); + + $config->usingChannel('chrome'); + + $config->usingExecutablePath('/usr/bin/google-chrome'); +})->throws(InvalidArgumentException::class); + +it('throws when combining an executable path with a channel', function (): void { + $config = new Configuration(); + + $config->usingExecutablePath('/usr/bin/google-chrome'); + + $config->usingChannel('chrome'); +})->throws(InvalidArgumentException::class); + +it('can override channel and executable path multiple times', function (): void { + Playwright::setChannel('chrome'); + expect(Playwright::channel())->toBe('chrome'); + + Playwright::setChannel('msedge'); + expect(Playwright::channel())->toBe('msedge'); + + Playwright::setExecutablePath('/usr/bin/google-chrome'); + expect(Playwright::executablePath())->toBe('/usr/bin/google-chrome'); + + Playwright::setExecutablePath(null); + expect(Playwright::executablePath())->toBeNull(); +}); diff --git a/tests/Unit/Playwright/ClientTest.php b/tests/Unit/Playwright/ClientTest.php new file mode 100644 index 00000000..f509dc40 --- /dev/null +++ b/tests/Unit/Playwright/ClientTest.php @@ -0,0 +1,49 @@ +toHaveKeys(['headless', 'ignoreHTTPSErrors', 'bypassCSP']); + expect($options)->not->toHaveKeys(['channel', 'executablePath']); +}); + +it('keeps headless disabled when running headed', function (): void { + Playwright::headed(); + + expect(Client::launchOptions()['headless'])->toBeFalse(); +}); + +it('includes the configured channel in the launch options', function (): void { + Playwright::setChannel('chrome'); + + expect(Client::launchOptions()['channel'])->toBe('chrome'); +}); + +it('includes the configured executable path in the launch options', function (): void { + Playwright::setExecutablePath('/usr/bin/google-chrome'); + + expect(Client::launchOptions()['executablePath'])->toBe('/usr/bin/google-chrome'); +}); + +it('builds a connection query that round-trips the launch options', function (): void { + $launchOptions = [ + 'headless' => false, + 'executablePath' => '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + ]; + + $query = Client::connectionQuery('chromium', $launchOptions); + + expect($query)->toContain('browser=chromium'); + expect($query)->toContain('launch-options='.urlencode((string) json_encode($launchOptions))); +});