From 508940b7ba10d1c2095ed866a1b336c21e577ffa Mon Sep 17 00:00:00 2001 From: Santos Vilanculos Date: Mon, 3 Aug 2026 14:19:56 +0200 Subject: [PATCH 1/2] feat: support system-installed browsers via channel and executable path Add two new configuration options to launch system-installed browsers instead of the bundled Playwright browsers: - pest()->browser()->usingChannel('chrome') for branded browsers such as Google Chrome or Microsoft Edge, which Playwright's server accepts out of the box. - pest()->browser()->usingExecutablePath('/usr/bin/firefox') for any browser binary; the Playwright server is started with --unsafe so the executablePath launch option is not filtered out. Both options are forwarded as launch options in the Playwright client. --- src/Configuration.php | 21 +++++++ src/Playwright/Client.php | 6 +- src/Playwright/Playwright.php | 42 +++++++++++++ src/ServerManager.php | 8 ++- .../SystemBrowserConfigurationTest.php | 63 +++++++++++++++++++ 5 files changed, 137 insertions(+), 3 deletions(-) create mode 100644 tests/Unit/Configuration/SystemBrowserConfigurationTest.php diff --git a/src/Configuration.php b/src/Configuration.php index bf3c458f..1b13a64d 100644 --- a/src/Configuration.php +++ b/src/Configuration.php @@ -45,6 +45,27 @@ public function inSafari(): self return $this; } + /** + * Uses a system-installed branded browser via its Playwright channel, + * e.g. "chrome", "chrome-beta", "msedge" or "msedge-dev". + */ + public function usingChannel(string $channel): self + { + Playwright::setChannel($channel); + + return $this; + } + + /** + * Uses the browser executable at the given path, e.g. "/usr/bin/firefox". + */ + public function usingExecutablePath(string $executablePath): self + { + Playwright::setExecutablePath($executablePath); + + return $this; + } + /** * Sets the theme to light mode. */ diff --git a/src/Playwright/Client.php b/src/Playwright/Client.php index 876e7af3..61e128af 100644 --- a/src/Playwright/Client.php +++ b/src/Playwright/Client.php @@ -51,11 +51,13 @@ public function connectTo(string $url): void if (! $this->websocketConnection instanceof WebsocketConnection) { $browser = Playwright::defaultBrowserType()->toPlaywrightName(); - $launchOptions = json_encode([ + $launchOptions = json_encode(array_filter([ 'headless' => Playwright::isHeadless(), 'ignoreHTTPSErrors' => true, 'bypassCSP' => true, - ]); + 'channel' => Playwright::channel(), + 'executablePath' => Playwright::executablePath(), + ], fn (mixed $value): bool => $value !== null)); $this->websocketConnection = connect( "ws://$url?browser=$browser&launch-options=$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..72a8126a --- /dev/null +++ b/tests/Unit/Configuration/SystemBrowserConfigurationTest.php @@ -0,0 +1,63 @@ +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/firefox'); + + expect($result)->toBeInstanceOf(Configuration::class); + expect(Playwright::executablePath())->toBe('/usr/bin/firefox'); +}); + +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') + ->usingExecutablePath('/usr/bin/msedge') + ->headed() + ->timeout(10000); + + expect($result)->toBeInstanceOf(Configuration::class); + expect(Playwright::channel())->toBe('msedge'); + expect(Playwright::executablePath())->toBe('/usr/bin/msedge'); +}); + +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/firefox'); + expect(Playwright::executablePath())->toBe('/usr/bin/firefox'); + + Playwright::setExecutablePath(null); + expect(Playwright::executablePath())->toBeNull(); +}); From 4ed01b9d9fd18c1f92aebe0a9919ef79e13a8e92 Mon Sep 17 00:00:00 2001 From: Santos Vilanculos Date: Mon, 10 Aug 2026 10:18:17 +0200 Subject: [PATCH 2/2] fix: encode launch options and scope system-browser channels - Restrict usingChannel() to Chromium-family channels (chrome, msedge and their beta/dev/canary variants) and document that Playwright only supports Chromium-family binaries via executablePath, so branded Firefox/Safari builds (e.g. /usr/bin/firefox) are not valid examples. - Make usingChannel() and usingExecutablePath() mutually exclusive by throwing an InvalidArgumentException when combined. - URL-encode the launch-options payload in the Playwright connection query instead of interpolating raw JSON, so paths with spaces or special characters round-trip correctly. - Extract launchOptions() and connectionQuery() on Client and cover the new behavior with tests. --- src/Configuration.php | 43 ++++++++++++++-- src/Playwright/Client.php | 39 +++++++++++---- .../SystemBrowserConfigurationTest.php | 39 ++++++++++++--- tests/Unit/Playwright/ClientTest.php | 49 +++++++++++++++++++ 4 files changed, 152 insertions(+), 18 deletions(-) create mode 100644 tests/Unit/Playwright/ClientTest.php diff --git a/src/Configuration.php b/src/Configuration.php index 1b13a64d..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; @@ -46,21 +47,37 @@ public function inSafari(): self } /** - * Uses a system-installed branded browser via its Playwright channel, - * e.g. "chrome", "chrome-beta", "msedge" or "msedge-dev". + * 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, e.g. "/usr/bin/firefox". + * 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; @@ -145,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 61e128af..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,16 +80,8 @@ public function connectTo(string $url): void if (! $this->websocketConnection instanceof WebsocketConnection) { $browser = Playwright::defaultBrowserType()->toPlaywrightName(); - $launchOptions = json_encode(array_filter([ - 'headless' => Playwright::isHeadless(), - 'ignoreHTTPSErrors' => true, - 'bypassCSP' => true, - 'channel' => Playwright::channel(), - 'executablePath' => Playwright::executablePath(), - ], fn (mixed $value): bool => $value !== null)); - $this->websocketConnection = connect( - "ws://$url?browser=$browser&launch-options=$launchOptions", + 'ws://'.$url.'?'.self::connectionQuery($browser, self::launchOptions()), ); } } diff --git a/tests/Unit/Configuration/SystemBrowserConfigurationTest.php b/tests/Unit/Configuration/SystemBrowserConfigurationTest.php index 72a8126a..f314de79 100644 --- a/tests/Unit/Configuration/SystemBrowserConfigurationTest.php +++ b/tests/Unit/Configuration/SystemBrowserConfigurationTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use InvalidArgumentException; use Pest\Browser\Configuration; use Pest\Browser\Playwright\Playwright; @@ -23,10 +24,10 @@ it('can set a browser executable path via configuration', function (): void { $config = new Configuration(); - $result = $config->usingExecutablePath('/usr/bin/firefox'); + $result = $config->usingExecutablePath('/usr/bin/google-chrome'); expect($result)->toBeInstanceOf(Configuration::class); - expect(Playwright::executablePath())->toBe('/usr/bin/firefox'); + expect(Playwright::executablePath())->toBe('/usr/bin/google-chrome'); }); it('defaults channel and executable path to null', function (): void { @@ -39,15 +40,41 @@ $result = $config ->usingChannel('msedge') - ->usingExecutablePath('/usr/bin/msedge') ->headed() ->timeout(10000); expect($result)->toBeInstanceOf(Configuration::class); expect(Playwright::channel())->toBe('msedge'); - expect(Playwright::executablePath())->toBe('/usr/bin/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'); @@ -55,8 +82,8 @@ Playwright::setChannel('msedge'); expect(Playwright::channel())->toBe('msedge'); - Playwright::setExecutablePath('/usr/bin/firefox'); - expect(Playwright::executablePath())->toBe('/usr/bin/firefox'); + 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))); +});