diff --git a/CHANGELOG.md b/CHANGELOG.md index a2682ee..39b00ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,17 @@ `BrowserContext::setOffline()`, `Dialog::page()`, `Keyboard::insertText()`, `Page::pause()`, `Response::headerValue()`. They will move to real interface declarations in the next major. +- `BrowserBuilder::withChannel()`, `withProxy()` and `withDownloadsPath()` + +### Fixed +- `PlaywrightConfig` now applies `channel`, `proxy`, `downloadsDir`, + `videosDir` and `minNodeVersion`. They were declared but never read, so + setting them had no effect. Anyone who worked around the `proxy` gap by + passing `--proxy-server` in `args` will now get both. +- `videosDir` applies to the default context as well, not only to contexts + created through `Browser::newContext()`. The server builds the default + context during launch, so its options now travel with the launch command. +- Proxy credentials no longer appear in the "Launching browser" log entry ## [1.2.0] - 2026-02-25 diff --git a/bin/playwright-server.js b/bin/playwright-server.js index 462cd31..dff4a4d 100644 --- a/bin/playwright-server.js +++ b/bin/playwright-server.js @@ -202,7 +202,7 @@ class PlaywrightServer extends BaseHandler { const browser = await browserType.launch(launchOptions); const browserId = this.generateId('browser'); this.browsers.set(browserId, browser); - const context = await browser.newContext(); + const context = await browser.newContext(command.contextOptions || {}); const contextId = this.generateId('context'); this.contexts.set(contextId, { context, browserId, id: contextId }); this.registerContextPopups(context, contextId); diff --git a/src/Browser/Browser.php b/src/Browser/Browser.php index 1adb323..71cf3bb 100644 --- a/src/Browser/Browser.php +++ b/src/Browser/Browser.php @@ -54,7 +54,7 @@ public function newContext(array $options = []): BrowserContextInterface $response = $this->transport->send([ 'action' => 'newContext', 'browserId' => $this->browserId, - 'options' => $options, + 'options' => array_merge($this->config->toContextOptions(), $options), ]); if (!is_string($response['contextId'])) { diff --git a/src/Browser/BrowserBuilder.php b/src/Browser/BrowserBuilder.php index 9901687..5084076 100644 --- a/src/Browser/BrowserBuilder.php +++ b/src/Browser/BrowserBuilder.php @@ -16,6 +16,7 @@ use Playwright\Configuration\PlaywrightConfig; use Playwright\Exception\PlaywrightException; +use Playwright\Transport\Sanitizer; use Playwright\Transport\TransportInterface; use Psr\Log\LoggerInterface; @@ -48,6 +49,30 @@ public function withSlowMo(int $milliseconds): self return $this; } + public function withChannel(string $channel): self + { + $this->launchOptions['channel'] = $channel; + + return $this; + } + + /** + * @param array{server: string, username?: string, password?: string, bypass?: string} $proxy + */ + public function withProxy(array $proxy): self + { + $this->launchOptions['proxy'] = $proxy; + + return $this; + } + + public function withDownloadsPath(string $path): self + { + $this->launchOptions['downloadsPath'] = $path; + + return $this; + } + /** * @param array $args */ @@ -73,15 +98,19 @@ public function withInspector(): self public function launch(): BrowserInterface { + // Launch options can carry proxy credentials, so never log them raw. $this->logger->info('Launching browser', [ 'browser' => $this->browserType, - 'options' => $this->launchOptions, + 'options' => Sanitizer::sanitizeParams($this->launchOptions), ]); $response = $this->transport->send([ 'action' => 'launch', 'browser' => $this->browserType, 'options' => $this->launchOptions, + // The server creates the default context during launch, so its + // options have to travel with the launch command. + 'contextOptions' => $this->config->toContextOptions(), ]); if (isset($response['error'])) { diff --git a/src/Configuration/PlaywrightConfig.php b/src/Configuration/PlaywrightConfig.php index e9ef7df..5e78d2e 100644 --- a/src/Configuration/PlaywrightConfig.php +++ b/src/Configuration/PlaywrightConfig.php @@ -26,11 +26,11 @@ final class PlaywrightConfig * @param array $args * @param array $env * @param array{ - * server?: string, + * server: string, * username?: string, * password?: string, * bypass?: string - * }|null $proxy + * }|null $proxy Playwright requires "server"; the other keys are optional */ public function __construct( public readonly ?string $nodePath = null, @@ -75,6 +75,28 @@ public function getScreenshotDirectory(): string return rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.'playwright'; } + /** + * Browser context options derived from this configuration. + * + * Only options the configuration actually sets are returned, so an unset + * one never reaches Playwright and its own default applies. + * + * @internal the shape of this bridge is not part of the public API and may + * change when context options gain a dedicated type + * + * @return array + */ + public function toContextOptions(): array + { + $options = []; + + if (null !== $this->videosDir) { + $options['recordVideo'] = ['dir' => $this->videosDir]; + } + + return $options; + } + /** * Return a new instance with the given node path. */ diff --git a/src/Configuration/PlaywrightConfigBuilder.php b/src/Configuration/PlaywrightConfigBuilder.php index 329697f..0fbf3af 100644 --- a/src/Configuration/PlaywrightConfigBuilder.php +++ b/src/Configuration/PlaywrightConfigBuilder.php @@ -60,7 +60,7 @@ final class PlaywrightConfigBuilder private bool $traceSnapshots = true; - /** @var array{server?: string, username?: string, password?: string, bypass?: string}|null */ + /** @var array{server: string, username?: string, password?: string, bypass?: string}|null */ private ?array $proxy = null; private ?LoggerInterface $logger = null; diff --git a/src/PlaywrightClient.php b/src/PlaywrightClient.php index def290c..797b480 100644 --- a/src/PlaywrightClient.php +++ b/src/PlaywrightClient.php @@ -172,6 +172,18 @@ private function createBrowserBuilder(string $browserType): BrowserBuilder $builder->withArgs($this->config->args); } + if (null !== $this->config->channel) { + $builder->withChannel($this->config->channel); + } + + if (null !== $this->config->proxy) { + $builder->withProxy($this->config->proxy); + } + + if (null !== $this->config->downloadsDir) { + $builder->withDownloadsPath($this->config->downloadsDir); + } + return $builder; } diff --git a/src/Transport/TransportFactory.php b/src/Transport/TransportFactory.php index eaff552..b207bcd 100644 --- a/src/Transport/TransportFactory.php +++ b/src/Transport/TransportFactory.php @@ -32,7 +32,7 @@ public function create(PlaywrightConfig $config, LoggerInterface $logger): Trans throw new RuntimeException('playwright-server.js not found.'); } - $nodePath = $config->nodePath ?? (new NodeBinaryResolver())->resolve(); + $nodePath = $config->nodePath ?? (new NodeBinaryResolver(minVersion: $config->minNodeVersion))->resolve(); $command = [$nodePath, $serverScriptPath]; $processLauncher = new ProcessLauncher($logger); diff --git a/tests/Functional/Download/DownloadPathTest.php b/tests/Functional/Download/DownloadPathTest.php new file mode 100644 index 0000000..9b773b3 --- /dev/null +++ b/tests/Functional/Download/DownloadPathTest.php @@ -0,0 +1,101 @@ +find('node'); + if (null === $node) { + self::markTestSkipped('Node.js executable not found.'); + } + + $downloadsDir = sys_get_temp_dir().'/pw-php-downloads-'.uniqid(); + mkdir($downloadsDir, 0777, true); + + $config = new PlaywrightConfig(nodePath: $node, downloadsDir: $downloadsDir); + + $playwright = PlaywrightFactory::create($config); + $browser = $playwright->chromium()->launch(); + + try { + $page = $browser->context()->newPage(); + $page->setContent( + 'download' + ); + $page->locator('#dl')->click(); + + // The download is asynchronous; Playwright stores the file under + // downloadsPath. Poll until it lands rather than guessing a delay. + $file = $this->waitForFirstFile($downloadsDir, 5.0); + + self::assertNotNull($file, 'no file appeared in the downloads directory'); + self::assertGreaterThan(0, (int) filesize($file)); + } finally { + $browser->close(); + $this->removeDirectory($downloadsDir); + } + } + + private function waitForFirstFile(string $directory, float $timeoutSeconds): ?string + { + $deadline = microtime(true) + $timeoutSeconds; + + do { + foreach (glob($directory.'/*') ?: [] as $entry) { + if (is_file($entry)) { + return $entry; + } + if (is_dir($entry)) { + foreach (glob($entry.'/*') ?: [] as $nested) { + if (is_file($nested)) { + return $nested; + } + } + } + } + usleep(100_000); + } while (microtime(true) < $deadline); + + return null; + } + + private function removeDirectory(string $directory): void + { + foreach (glob($directory.'/*') ?: [] as $entry) { + if (is_dir($entry)) { + foreach (glob($entry.'/*') ?: [] as $nested) { + @unlink($nested); + } + @rmdir($entry); + } else { + @unlink($entry); + } + } + + if (is_dir($directory)) { + @rmdir($directory); + } + } +} diff --git a/tests/Functional/Network/ProxyTest.php b/tests/Functional/Network/ProxyTest.php new file mode 100644 index 0000000..285bb0b --- /dev/null +++ b/tests/Functional/Network/ProxyTest.php @@ -0,0 +1,62 @@ +find('node'); + if (null === $node) { + self::markTestSkipped('Node.js executable not found.'); + } + + // A dead proxy (nothing listens on port 1) plus a non-loopback target, + // so Chromium does not bypass it. If the proxy reaches the browser, the + // failure is a proxy-connection error; if it were ignored, the target + // would instead fail to resolve. The error kind is the assertion. + $config = new PlaywrightConfig( + nodePath: $node, + proxy: ['server' => 'http://127.0.0.1:1'], + ); + + $playwright = PlaywrightFactory::create($config); + $browser = $playwright->chromium()->launch(); + + try { + $page = $browser->context()->newPage(); + + $message = null; + try { + $page->goto('http://example.invalid/'); + } catch (\Throwable $e) { + $message = $e->getMessage(); + } + + self::assertNotNull($message, 'navigation through a dead proxy should fail'); + self::assertStringContainsStringIgnoringCase('PROXY', $message); + } finally { + $browser->close(); + } + } +} diff --git a/tests/Functional/Video/VideoRecordingTest.php b/tests/Functional/Video/VideoRecordingTest.php new file mode 100644 index 0000000..865c09d --- /dev/null +++ b/tests/Functional/Video/VideoRecordingTest.php @@ -0,0 +1,100 @@ +requireNode(); + $videosDir = sys_get_temp_dir().'/pw-php-videos-'.uniqid(); + + $config = new PlaywrightConfig(nodePath: $node, videosDir: $videosDir); + + $playwright = PlaywrightFactory::create($config); + $browser = $playwright->chromium()->launch(); + + try { + $context = $browser->newContext(); + $page = $context->newPage(); + $page->goto($this->getBaseUrl().'/index.html'); + + // Playwright flushes the video file when the context closes. + $context->close(); + + $videos = glob($videosDir.'/*.webm') ?: []; + self::assertCount(1, $videos); + self::assertGreaterThan(0, (int) filesize($videos[0])); + } finally { + $browser->close(); + $this->removeDirectory($videosDir); + } + } + + public function testTheDefaultContextRecordsVideoToo(): void + { + $node = $this->requireNode(); + $videosDir = sys_get_temp_dir().'/pw-php-videos-default-'.uniqid(); + + $config = new PlaywrightConfig(nodePath: $node, videosDir: $videosDir); + + $playwright = PlaywrightFactory::create($config); + $browser = $playwright->chromium()->launch(); + + try { + // No newContext() call: this is the context the server built at launch. + $page = $browser->context()->newPage(); + $page->goto($this->getBaseUrl().'/index.html'); + + $browser->context()->close(); + + $videos = glob($videosDir.'/*.webm') ?: []; + self::assertCount(1, $videos); + self::assertGreaterThan(0, (int) filesize($videos[0])); + } finally { + $browser->close(); + $this->removeDirectory($videosDir); + } + } + + private function requireNode(): string + { + $node = (new ExecutableFinder())->find('node'); + if (null === $node) { + self::markTestSkipped('Node.js executable not found.'); + } + + return $node; + } + + private function removeDirectory(string $directory): void + { + foreach (glob($directory.'/*') ?: [] as $file) { + @unlink($file); + } + + if (is_dir($directory)) { + @rmdir($directory); + } + } +} diff --git a/tests/Integration/Browser/BrowserBuilderTest.php b/tests/Integration/Browser/BrowserBuilderTest.php index 02ff2fd..b50c8c7 100644 --- a/tests/Integration/Browser/BrowserBuilderTest.php +++ b/tests/Integration/Browser/BrowserBuilderTest.php @@ -21,6 +21,7 @@ use Playwright\Browser\BrowserBuilder; use Playwright\Configuration\PlaywrightConfig; use Playwright\Transport\TransportInterface; +use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; #[CoversClass(BrowserBuilder::class)] @@ -174,4 +175,98 @@ public function itCanConnectOverCDP(): void $this->assertInstanceOf(Browser::class, $browser); } + + #[Test] + public function itSendsTheChannelInTheLaunchPayload(): void + { + $this->transport->expects($this->once()) + ->method('send') + ->with($this->callback(static function (array $message): bool { + return 'launch' === $message['action'] + && 'msedge' === $message['options']['channel']; + })) + ->willReturn(['browserId' => 'b', 'defaultContextId' => 'c', 'version' => '1.0']); + + $this->builder->withChannel('msedge')->launch(); + } + + #[Test] + public function itSendsTheFullProxyArrayInTheLaunchPayload(): void + { + $proxy = [ + 'server' => 'http://proxy.local:8080', + 'username' => 'user', + 'password' => 'secret', + 'bypass' => 'localhost', + ]; + + $this->transport->expects($this->once()) + ->method('send') + ->with($this->callback(static function (array $message) use ($proxy): bool { + return 'launch' === $message['action'] + && $proxy === $message['options']['proxy']; + })) + ->willReturn(['browserId' => 'b', 'defaultContextId' => 'c', 'version' => '1.0']); + + $this->builder->withProxy($proxy)->launch(); + } + + #[Test] + public function itSendsTheDownloadsPathInTheLaunchPayload(): void + { + $this->transport->expects($this->once()) + ->method('send') + ->with($this->callback(static function (array $message): bool { + return 'launch' === $message['action'] + && '/tmp/downloads' === $message['options']['downloadsPath']; + })) + ->willReturn(['browserId' => 'b', 'defaultContextId' => 'c', 'version' => '1.0']); + + $this->builder->withDownloadsPath('/tmp/downloads')->launch(); + } + + #[Test] + public function itDoesNotLogTheProxyPassword(): void + { + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->once()) + ->method('info') + ->with('Launching browser', $this->callback(static function (array $context): bool { + return '[REDACTED]' === $context['options']['proxy']['password']; + })); + + $transport = $this->createMock(TransportInterface::class); + $transport->method('send') + ->willReturn(['browserId' => 'b', 'defaultContextId' => 'c', 'version' => '1.0']); + + $builder = new BrowserBuilder('chromium', $transport, $logger, new PlaywrightConfig()); + + $builder->withProxy([ + 'server' => 'http://proxy.local:8080', + 'username' => 'user', + 'password' => 'secret', + ])->launch(); + } + + #[Test] + public function itSendsContextOptionsSoTheDefaultContextRecordsVideo(): void + { + $transport = $this->createMock(TransportInterface::class); + $transport->expects($this->once()) + ->method('send') + ->with($this->callback(static function (array $message): bool { + return 'launch' === $message['action'] + && ['dir' => '/tmp/videos'] === $message['contextOptions']['recordVideo']; + })) + ->willReturn(['browserId' => 'b', 'defaultContextId' => 'c', 'version' => '1.0']); + + $builder = new BrowserBuilder( + 'chromium', + $transport, + new NullLogger(), + new PlaywrightConfig(videosDir: '/tmp/videos'), + ); + + $builder->launch(); + } } diff --git a/tests/Integration/PlaywrightClientTest.php b/tests/Integration/PlaywrightClientTest.php index 0c34432..e2f7dad 100644 --- a/tests/Integration/PlaywrightClientTest.php +++ b/tests/Integration/PlaywrightClientTest.php @@ -18,6 +18,7 @@ use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; use Playwright\Browser\BrowserBuilder; +use Playwright\Configuration\PlaywrightConfig; use Playwright\Configuration\PlaywrightConfigBuilder; use Playwright\PlaywrightClient; use Playwright\Transport\TransportInterface; @@ -120,4 +121,71 @@ public function itClosesConnectionOnDestruct(): void $this->client->chromium(); unset($this->client); } + + #[Test] + public function itAppliesChannelProxyAndDownloadsDirFromTheConfig(): void + { + $proxy = ['server' => 'http://proxy.local:8080']; + + $transport = $this->createMock(TransportInterface::class); + $transport->expects($this->once()) + ->method('send') + ->with($this->callback(static function (array $message) use ($proxy): bool { + return 'launch' === $message['action'] + && 'chrome-beta' === $message['options']['channel'] + && $proxy === $message['options']['proxy'] + && '/var/downloads' === $message['options']['downloadsPath']; + })) + ->willReturn(['browserId' => 'b', 'defaultContextId' => 'c', 'version' => '1.0']); + + $config = new PlaywrightConfig( + nodePath: '/usr/bin/node', + channel: 'chrome-beta', + downloadsDir: '/var/downloads', + proxy: $proxy, + ); + + (new PlaywrightClient($transport, new NullLogger(), $config))->chromium()->launch(); + } + + #[Test] + public function itLeavesUnsetConfigOptionsOutOfTheLaunchPayload(): void + { + $transport = $this->createMock(TransportInterface::class); + $transport->expects($this->once()) + ->method('send') + ->with($this->callback(static function (array $message): bool { + return 'launch' === $message['action'] + && !array_key_exists('channel', $message['options']) + && !array_key_exists('proxy', $message['options']) + && !array_key_exists('downloadsPath', $message['options']); + })) + ->willReturn(['browserId' => 'b', 'defaultContextId' => 'c', 'version' => '1.0']); + + $config = new PlaywrightConfig(nodePath: '/usr/bin/node'); + + (new PlaywrightClient($transport, new NullLogger(), $config))->chromium()->launch(); + } + + #[Test] + public function itLetsAnExplicitSetterOverrideTheConfig(): void + { + $transport = $this->createMock(TransportInterface::class); + $transport->expects($this->once()) + ->method('send') + ->with($this->callback(static function (array $message): bool { + return 'msedge' === $message['options']['channel']; + })) + ->willReturn(['browserId' => 'b', 'defaultContextId' => 'c', 'version' => '1.0']); + + $config = new PlaywrightConfig( + nodePath: '/usr/bin/node', + channel: 'chrome-beta', + ); + + (new PlaywrightClient($transport, new NullLogger(), $config)) + ->chromium() + ->withChannel('msedge') + ->launch(); + } } diff --git a/tests/Integration/Transport/TransportFactoryTest.php b/tests/Integration/Transport/TransportFactoryTest.php index 4be99c9..6b82ad0 100644 --- a/tests/Integration/Transport/TransportFactoryTest.php +++ b/tests/Integration/Transport/TransportFactoryTest.php @@ -18,6 +18,7 @@ use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; use Playwright\Configuration\PlaywrightConfig; +use Playwright\Node\Exception\NodeVersionTooLowException; use Playwright\Transport\JsonRpc\JsonRpcTransport; use Playwright\Transport\TransportFactory; use Psr\Log\NullLogger; @@ -88,4 +89,15 @@ public function itCanCreateTransportWithCustomTimeout(): void $this->assertInstanceOf(JsonRpcTransport::class, $transport); } + + #[Test] + public function itEnforcesTheConfiguredMinimumNodeVersion(): void + { + $config = new PlaywrightConfig(minNodeVersion: '999.0.0'); + + $this->expectException(NodeVersionTooLowException::class); + $this->expectExceptionMessageMatches('/need >= 999\.0\.0/'); + + $this->factory->create($config, $this->logger); + } } diff --git a/tests/Unit/Browser/BrowserNewContextTest.php b/tests/Unit/Browser/BrowserNewContextTest.php new file mode 100644 index 0000000..326efc0 --- /dev/null +++ b/tests/Unit/Browser/BrowserNewContextTest.php @@ -0,0 +1,80 @@ +recordingTransport($sent); + + $browser = new Browser($transport, 'b', 'ctx_default', '1.0', new PlaywrightConfig( + videosDir: '/tmp/videos', + )); + + $browser->newContext(); + + $this->assertSame(['dir' => '/tmp/videos'], $sent[0]['options']['recordVideo']); + } + + public function testNewContextLeavesRecordVideoOutWhenVideosDirIsUnset(): void + { + $sent = []; + $transport = $this->recordingTransport($sent); + + $browser = new Browser($transport, 'b', 'ctx_default', '1.0', new PlaywrightConfig()); + + $browser->newContext(); + + $this->assertArrayNotHasKey('recordVideo', $sent[0]['options']); + } + + public function testExplicitOptionsOverrideTheConfig(): void + { + $sent = []; + $transport = $this->recordingTransport($sent); + + $browser = new Browser($transport, 'b', 'ctx_default', '1.0', new PlaywrightConfig( + videosDir: '/tmp/videos', + )); + + $browser->newContext(['recordVideo' => ['dir' => '/elsewhere']]); + + $this->assertSame(['dir' => '/elsewhere'], $sent[0]['options']['recordVideo']); + } + + /** + * @param array> $sent + */ + private function recordingTransport(array &$sent): TransportInterface + { + $transport = $this->createMock(TransportInterface::class); + $transport->method('send')->willReturnCallback(function (array $payload) use (&$sent): array { + $sent[] = $payload; + + return ['contextId' => 'ctx_new']; + }); + + return $transport; + } +} diff --git a/tests/Unit/Configuration/PlaywrightConfigTest.php b/tests/Unit/Configuration/PlaywrightConfigTest.php index 0a80991..debc9a2 100644 --- a/tests/Unit/Configuration/PlaywrightConfigTest.php +++ b/tests/Unit/Configuration/PlaywrightConfigTest.php @@ -238,4 +238,18 @@ public function itCreateNewInstanceWithModifiedNodePath(): void $this->assertEquals('/original/node', $config->nodePath); $this->assertEquals('/new/node', $newConfig->nodePath); } + + #[Test] + public function toContextOptionsMapsVideosDirToRecordVideo(): void + { + $config = new PlaywrightConfig(videosDir: '/tmp/videos'); + + $this->assertSame(['recordVideo' => ['dir' => '/tmp/videos']], $config->toContextOptions()); + } + + #[Test] + public function toContextOptionsIsEmptyWhenNoContextOptionIsSet(): void + { + $this->assertSame([], (new PlaywrightConfig())->toContextOptions()); + } }