diff --git a/CHANGELOG.md b/CHANGELOG.md index 5546de8..728f324 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## [Unreleased] +### Added +- Launching a browser from a custom path through `withExecutablePath()` and `PW_EXECUTABLE_PATH` + ## [1.5.0] - 2026-09-20 ### Added diff --git a/docs/guide/browsers.md b/docs/guide/browsers.md index 7afa7df..8047226 100644 --- a/docs/guide/browsers.md +++ b/docs/guide/browsers.md @@ -101,6 +101,28 @@ vendor/bin/playwright-install chrome `PW_CHANNEL=chrome` provides the same launch choice when configuration is built with `PlaywrightConfigBuilder::fromEnv()`. +## Launch a browser from a custom path + +An executable path launches a browser binary that Playwright does not manage, +such as a Chromium-based browser installed on the machine. Brave on macOS: + +```php +$config = PlaywrightConfigBuilder::create() + ->withExecutablePath('/Applications/Brave Browser.app/Contents/MacOS/Brave Browser') + ->build(); + +$playwright = PlaywrightFactory::create($config); +$browser = $playwright->chromium()->launch(); +``` + +The binary must match the launched browser type. Firefox and WebKit still need +Playwright's patched builds, so in practice this is for Chromium-based +browsers. Playwright does not guarantee that versions other than its bundled +one work. + +`PW_EXECUTABLE_PATH` provides the same launch choice when configuration is +built with `PlaywrightConfigBuilder::fromEnv()`. + ## Keep the browser cache consistent Set `PLAYWRIGHT_BROWSERS_PATH` during installation and at runtime when managed diff --git a/src/Browser/BrowserBuilder.php b/src/Browser/BrowserBuilder.php index b2abbcf..4945816 100644 --- a/src/Browser/BrowserBuilder.php +++ b/src/Browser/BrowserBuilder.php @@ -56,6 +56,16 @@ public function withChannel(string $channel): self return $this; } + /** + * Launches the browser binary at this path instead of the bundled one. + */ + public function withExecutablePath(string $path): self + { + $this->launchOptions['executablePath'] = $path; + + return $this; + } + /** * @param array{server: string, username?: string, password?: string, bypass?: string} $proxy */ diff --git a/src/Configuration/PlaywrightConfig.php b/src/Configuration/PlaywrightConfig.php index 5e78d2e..bcf9b0b 100644 --- a/src/Configuration/PlaywrightConfig.php +++ b/src/Configuration/PlaywrightConfig.php @@ -58,6 +58,8 @@ public function __construct( public readonly ?array $proxy = null, public readonly ?LoggerInterface $logger = null, + + public readonly ?string $executablePath = null, ) { } @@ -121,6 +123,7 @@ public function withNodePath(?string $nodePath): self traceSnapshots: $this->traceSnapshots, proxy: $this->proxy, logger: $this->logger, + executablePath: $this->executablePath, ); } @@ -136,6 +139,7 @@ public function toArray(): array 'minNodeVersion' => $this->minNodeVersion, 'browser' => $this->browser->value, 'channel' => $this->channel, + 'executablePath' => $this->executablePath, 'headless' => $this->headless, 'timeoutMs' => $this->timeoutMs, 'slowMoMs' => $this->slowMoMs, diff --git a/src/Configuration/PlaywrightConfigBuilder.php b/src/Configuration/PlaywrightConfigBuilder.php index 0fbf3af..b319bd9 100644 --- a/src/Configuration/PlaywrightConfigBuilder.php +++ b/src/Configuration/PlaywrightConfigBuilder.php @@ -36,6 +36,8 @@ final class PlaywrightConfigBuilder private ?string $channel = null; + private ?string $executablePath = null; + private bool $headless = true; private int $timeoutMs = 30_000; @@ -105,6 +107,9 @@ public static function fromEnv(): self if ($channel = $get('PW_CHANNEL')) { $b->withChannel($channel); } + if ($executablePath = $get('PW_EXECUTABLE_PATH')) { + $b->withExecutablePath($executablePath); + } if (($headless = $get('PW_HEADLESS')) !== null) { $b->withHeadless(self::strToBool($headless)); } @@ -166,6 +171,13 @@ public function withChannel(?string $channel): self return $this; } + public function withExecutablePath(?string $executablePath): self + { + $this->executablePath = $executablePath ?: null; + + return $this; + } + public function withHeadless(bool $headless): self { $this->headless = $headless; @@ -309,7 +321,8 @@ public function build(): PlaywrightConfig traceScreenshots: $this->traceScreenshots, traceSnapshots: $this->traceSnapshots, proxy: $this->proxy, - logger: $this->logger + logger: $this->logger, + executablePath: $this->executablePath, ); } diff --git a/src/PlaywrightClient.php b/src/PlaywrightClient.php index 797b480..819fc62 100644 --- a/src/PlaywrightClient.php +++ b/src/PlaywrightClient.php @@ -176,6 +176,10 @@ private function createBrowserBuilder(string $browserType): BrowserBuilder $builder->withChannel($this->config->channel); } + if (null !== $this->config->executablePath) { + $builder->withExecutablePath($this->config->executablePath); + } + if (null !== $this->config->proxy) { $builder->withProxy($this->config->proxy); } diff --git a/tests/Integration/Browser/BrowserBuilderTest.php b/tests/Integration/Browser/BrowserBuilderTest.php index b50c8c7..e30208a 100644 --- a/tests/Integration/Browser/BrowserBuilderTest.php +++ b/tests/Integration/Browser/BrowserBuilderTest.php @@ -190,6 +190,20 @@ public function itSendsTheChannelInTheLaunchPayload(): void $this->builder->withChannel('msedge')->launch(); } + #[Test] + public function itSendsTheExecutablePathInTheLaunchPayload(): void + { + $this->transport->expects($this->once()) + ->method('send') + ->with($this->callback(static function (array $message): bool { + return 'launch' === $message['action'] + && '/usr/bin/chromium' === $message['options']['executablePath']; + })) + ->willReturn(['browserId' => 'b', 'defaultContextId' => 'c', 'version' => '1.0']); + + $this->builder->withExecutablePath('/usr/bin/chromium')->launch(); + } + #[Test] public function itSendsTheFullProxyArrayInTheLaunchPayload(): void { diff --git a/tests/Integration/PlaywrightClientTest.php b/tests/Integration/PlaywrightClientTest.php index e2f7dad..905ae11 100644 --- a/tests/Integration/PlaywrightClientTest.php +++ b/tests/Integration/PlaywrightClientTest.php @@ -148,6 +148,26 @@ public function itAppliesChannelProxyAndDownloadsDirFromTheConfig(): void (new PlaywrightClient($transport, new NullLogger(), $config))->chromium()->launch(); } + #[Test] + public function itAppliesExecutablePathFromTheConfig(): void + { + $transport = $this->createMock(TransportInterface::class); + $transport->expects($this->once()) + ->method('send') + ->with($this->callback(static function (array $message): bool { + return 'launch' === $message['action'] + && '/usr/bin/chromium' === $message['options']['executablePath']; + })) + ->willReturn(['browserId' => 'b', 'defaultContextId' => 'c', 'version' => '1.0']); + + $config = new PlaywrightConfig( + nodePath: '/usr/bin/node', + executablePath: '/usr/bin/chromium', + ); + + (new PlaywrightClient($transport, new NullLogger(), $config))->chromium()->launch(); + } + #[Test] public function itLeavesUnsetConfigOptionsOutOfTheLaunchPayload(): void { @@ -157,6 +177,7 @@ public function itLeavesUnsetConfigOptionsOutOfTheLaunchPayload(): void ->with($this->callback(static function (array $message): bool { return 'launch' === $message['action'] && !array_key_exists('channel', $message['options']) + && !array_key_exists('executablePath', $message['options']) && !array_key_exists('proxy', $message['options']) && !array_key_exists('downloadsPath', $message['options']); })) diff --git a/tests/Unit/Configuration/PlaywrightConfigBuilderTest.php b/tests/Unit/Configuration/PlaywrightConfigBuilderTest.php index 46306da..981a356 100644 --- a/tests/Unit/Configuration/PlaywrightConfigBuilderTest.php +++ b/tests/Unit/Configuration/PlaywrightConfigBuilderTest.php @@ -32,6 +32,7 @@ private function clearEnvironment(): void 'PLAYWRIGHT_BROWSERS_PATH', 'PW_BROWSER', 'PW_CHANNEL', + 'PW_EXECUTABLE_PATH', 'PW_HEADLESS', 'PW_TIMEOUT_MS', 'PW_SLOWMO_MS', @@ -101,6 +102,7 @@ public function itSupportsFluentInterface(): void ->withNodePath('/usr/local/bin/node') ->withMinNodeVersion('20.0.0') ->withChannel('stable') + ->withExecutablePath('/usr/bin/chromium') ->withDownloadsDir('/tmp/downloads') ->withVideosDir('/tmp/videos') ->withLogger($logger) @@ -113,6 +115,7 @@ public function itSupportsFluentInterface(): void $this->assertEquals('/usr/local/bin/node', $config->nodePath); $this->assertEquals('20.0.0', $config->minNodeVersion); $this->assertEquals('stable', $config->channel); + $this->assertEquals('/usr/bin/chromium', $config->executablePath); $this->assertEquals('/tmp/downloads', $config->downloadsDir); $this->assertEquals('/tmp/videos', $config->videosDir); $this->assertSame($logger, $config->logger); @@ -239,6 +242,7 @@ public function itCreatesFromEnvironmentVariables(): void putenv('PLAYWRIGHT_BROWSERS_PATH=/env/ms-playwright'); putenv('PW_BROWSER=firefox'); putenv('PW_CHANNEL=nightly'); + putenv('PW_EXECUTABLE_PATH=/env/chromium'); putenv('PW_HEADLESS=false'); putenv('PW_TIMEOUT_MS=60000'); putenv('PW_SLOWMO_MS=500'); @@ -258,6 +262,7 @@ public function itCreatesFromEnvironmentVariables(): void $this->assertEquals('/env/ms-playwright', $config->env['PLAYWRIGHT_BROWSERS_PATH'] ?? null); $this->assertEquals(BrowserType::FIREFOX, $config->browser); $this->assertEquals('nightly', $config->channel); + $this->assertEquals('/env/chromium', $config->executablePath); $this->assertFalse($config->headless); $this->assertEquals(60000, $config->timeoutMs); $this->assertEquals(500, $config->slowMoMs); @@ -329,12 +334,14 @@ public function itSanitizesEmptyStrings(): void { $config = PlaywrightConfigBuilder::create() ->withChannel('') + ->withExecutablePath('') ->withDownloadsDir('') ->withVideosDir('') ->withTracing(true, '') ->build(); $this->assertNull($config->channel); + $this->assertNull($config->executablePath); $this->assertNull($config->downloadsDir); $this->assertNull($config->videosDir); $this->assertNull($config->traceDir);