From 4587410d246d6732166a388ba053f3567fce437d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Andr=C3=A9?= Date: Thu, 23 Jul 2026 19:56:10 +0200 Subject: [PATCH] Wire config options into launch and context creation PlaywrightConfig declared channel, proxy, downloadsDir, videosDir and minNodeVersion but nothing read them. Apply them: - channel, proxy and downloadsPath through new BrowserBuilder setters, applied in PlaywrightClient::createBrowserBuilder(); an explicit setter call wins over the config. - minNodeVersion passed to the NodeBinaryResolver in TransportFactory. - videosDir mapped to recordVideo.dir via PlaywrightConfig::toContextOptions() (@internal), applied both to newContext() and to the default context the server creates at launch (the launch command now carries contextOptions). Proxy credentials are sanitized out of the launch log. The proxy array type requires "server", matching Playwright. Covered end to end: video, download and proxy land in the browser; patch coverage on the changed code is 100%. --- CHANGELOG.md | 11 ++ bin/playwright-server.js | 2 +- src/Browser/Browser.php | 2 +- src/Browser/BrowserBuilder.php | 31 +++++- src/Configuration/PlaywrightConfig.php | 26 ++++- src/Configuration/PlaywrightConfigBuilder.php | 2 +- src/PlaywrightClient.php | 12 +++ src/Transport/TransportFactory.php | 2 +- .../Functional/Download/DownloadPathTest.php | 101 ++++++++++++++++++ tests/Functional/Network/ProxyTest.php | 62 +++++++++++ tests/Functional/Video/VideoRecordingTest.php | 100 +++++++++++++++++ .../Browser/BrowserBuilderTest.php | 95 ++++++++++++++++ tests/Integration/PlaywrightClientTest.php | 68 ++++++++++++ .../Transport/TransportFactoryTest.php | 12 +++ tests/Unit/Browser/BrowserNewContextTest.php | 80 ++++++++++++++ .../Configuration/PlaywrightConfigTest.php | 14 +++ 16 files changed, 613 insertions(+), 7 deletions(-) create mode 100644 tests/Functional/Download/DownloadPathTest.php create mode 100644 tests/Functional/Network/ProxyTest.php create mode 100644 tests/Functional/Video/VideoRecordingTest.php create mode 100644 tests/Unit/Browser/BrowserNewContextTest.php 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()); + } }