Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion bin/playwright-server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion src/Browser/Browser.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'])) {
Expand Down
31 changes: 30 additions & 1 deletion src/Browser/BrowserBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

use Playwright\Configuration\PlaywrightConfig;
use Playwright\Exception\PlaywrightException;
use Playwright\Transport\Sanitizer;
use Playwright\Transport\TransportInterface;
use Psr\Log\LoggerInterface;

Expand Down Expand Up @@ -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<string> $args
*/
Expand All @@ -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'])) {
Expand Down
26 changes: 24 additions & 2 deletions src/Configuration/PlaywrightConfig.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,11 @@ final class PlaywrightConfig
* @param array<int, string> $args
* @param array<string, string> $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,
Expand Down Expand Up @@ -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<string, mixed>
*/
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.
*/
Expand Down
2 changes: 1 addition & 1 deletion src/Configuration/PlaywrightConfigBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
12 changes: 12 additions & 0 deletions src/PlaywrightClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
2 changes: 1 addition & 1 deletion src/Transport/TransportFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
101 changes: 101 additions & 0 deletions tests/Functional/Download/DownloadPathTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
<?php

declare(strict_types=1);

/*
* This file is part of the community-maintained Playwright PHP project.
* It is not affiliated with or endorsed by Microsoft.
*
* (c) 2025-Present - Playwright PHP - https://github.com/playwright-php
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Playwright\Tests\Functional\Download;

use PHPUnit\Framework\Attributes\CoversClass;
use Playwright\Browser\BrowserBuilder;
use Playwright\Configuration\PlaywrightConfig;
use Playwright\PlaywrightFactory;
use Playwright\Tests\Functional\FunctionalTestCase;
use Symfony\Component\Process\ExecutableFinder;

#[CoversClass(BrowserBuilder::class)]
final class DownloadPathTest extends FunctionalTestCase
{
public function testAClickedDownloadLandsInTheConfiguredDirectory(): void
{
$node = (new ExecutableFinder())->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(
'<a id="dl" href="data:text/plain,hello%20world" download="hello.txt">download</a>'
);
$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);
}
}
}
62 changes: 62 additions & 0 deletions tests/Functional/Network/ProxyTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<?php

declare(strict_types=1);

/*
* This file is part of the community-maintained Playwright PHP project.
* It is not affiliated with or endorsed by Microsoft.
*
* (c) 2025-Present - Playwright PHP - https://github.com/playwright-php
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Playwright\Tests\Functional\Network;

use PHPUnit\Framework\Attributes\CoversClass;
use Playwright\Browser\BrowserBuilder;
use Playwright\Configuration\PlaywrightConfig;
use Playwright\PlaywrightFactory;
use Playwright\Tests\Functional\FunctionalTestCase;
use Symfony\Component\Process\ExecutableFinder;

#[CoversClass(BrowserBuilder::class)]
final class ProxyTest extends FunctionalTestCase
{
public function testNavigationIsRoutedThroughTheConfiguredProxy(): void
{
$node = (new ExecutableFinder())->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();
}
}
}
Loading
Loading