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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@
- Read recorded JavaScript errors with `PageInterface::pageErrors()` (#179). Custom `PageInterface` implementations must add this method.
- Remote browser configuration through `PlaywrightConfig::wsEndpoint`, `withWsEndpoint()` and the `PW_WS_ENDPOINT` / `PLAYWRIGHT_WS_ENDPOINT` environment variables (#195)

### Fixed
- Use `PlaywrightConfig::logger` for page and locator diagnostics, with redacted navigation URLs and command errors (#83)

## [1.5.1] - 2026-11-06

### Fixed
Expand Down
15 changes: 15 additions & 0 deletions docs/guide/advanced-recipes.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ power of Playwright for PHP.
* [Working with Frames](#working-with-frames)
* [Connecting to External Browsers](#connecting-to-external-browsers)
* [Browser Configuration with `PlaywrightConfigBuilder`](#browser-configuration-with-playwrightconfigbuilder)
* [Page and Locator Logging](#page-and-locator-logging)

## File Uploads

Expand Down Expand Up @@ -158,3 +159,17 @@ $playwright = PlaywrightFactory::create($config);
// Now launch the browser, which will use these settings
$browser = $playwright->chromium()->launch();
```

## Page and Locator Logging

Set a PSR-3 logger on `PlaywrightConfig` to log page operations and locator failures, including locators inside frames.

```php
use Playwright\Configuration\PlaywrightConfig;
use Playwright\PlaywrightFactory;

$config = new PlaywrightConfig(logger: $logger);
$playwright = PlaywrightFactory::create($config);
```

Use the factory's second argument to configure client and transport logging. You can pass the same logger in both places.
10 changes: 8 additions & 2 deletions src/Locator/Locator.php
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
use Playwright\Locator\Options\WaitForOptions;
use Playwright\Page\PageInterface;
use Playwright\Screenshot\ScreenshotComparison;
use Playwright\Transport\Sanitizer;
use Playwright\Transport\TransportInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
Expand Down Expand Up @@ -514,8 +515,8 @@ public function setInputFiles(string|array $files, array|SetInputFilesOptions $o
$this->logger->error('Failed to set input files on locator', [
'selector' => (string) $this->selectorChain,
'files' => $fileArray,
'error' => $e->getMessage(),
'exception' => $e,
'error' => Sanitizer::sanitizeParams($e->getMessage()),
'exceptionType' => $e::class,
]);
throw $e;
}
Expand Down Expand Up @@ -816,6 +817,11 @@ private function sendCommand(string $action, array $params = []): array
if (isset($response['error'])) {
$error = $response['error'];
$errorMessage = is_string($error) ? $error : 'Unknown locator error';
$this->logger->error('Locator command failed', [
'action' => $action,
'error' => Sanitizer::sanitizeParams($errorMessage),
]);

throw new PlaywrightException($errorMessage);
}

Expand Down
44 changes: 25 additions & 19 deletions src/Page/Page.php
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@
use Playwright\Screencast\ScreencastInterface;
use Playwright\Screenshot\ScreenshotComparison;
use Playwright\Screenshot\ScreenshotHelper;
use Playwright\Transport\Sanitizer;
use Playwright\Transport\TransportInterface;
use Playwright\Video\Video;
use Playwright\Video\VideoInterface;
Expand All @@ -104,6 +105,8 @@ final class Page implements PageInterface, EventDispatcherInterface

private PageEventHandlerInterface $eventHandler;

private readonly LoggerInterface $logger;

private ?APIRequestContextInterface $apiRequestContext = null;

private bool $isClosed = false;
Expand All @@ -123,8 +126,10 @@ public function __construct(
private readonly BrowserContextInterface $context,
private readonly string $pageId,
private readonly PlaywrightConfig $config,
private readonly LoggerInterface $logger = new NullLogger(),
?LoggerInterface $logger = null,

This comment was marked as outdated.

) {
$this->logger = $logger ?? $config->logger ?? new NullLogger();
Comment thread
smnandre marked this conversation as resolved.
Resolved

$this->keyboard = new Keyboard($this->transport, $this->pageId);
$this->mouse = new Mouse($this->transport, $this->pageId);
$this->touchscreen = new Touchscreen($this->transport, $this->pageId);
Expand Down Expand Up @@ -262,7 +267,7 @@ public function locator(string $selector, array|LocatorOptions $options = []): L
$this->pageId,
$selector,
null,
null,
$this->logger,
$this->normalizeLocatorOptions($options),
$this,
);
Expand Down Expand Up @@ -355,21 +360,22 @@ public function getByTitle(string|Regex $text, array|LocatorOptions $options = [
public function goto(string $url, array|GotoOptions $options = []): ?ResponseInterface
{
$options = GotoOptions::from($options)->toArray();
$logUrl = Sanitizer::sanitizeUrl($url);

$this->logger->debug('Navigating to URL', ['url' => $url, 'options' => $options]);
$this->logger->debug('Navigating to URL', ['url' => $logUrl, 'options' => Sanitizer::sanitizeParams($options)]);

try {
$response = $this->sendCommand('goto', ['url' => $url, 'options' => $options]);
$this->logger->info('Successfully navigated to URL', ['url' => $url]);
$this->logger->info('Successfully navigated to URL', ['url' => $logUrl]);

$responseData = $response['response'] ?? null;

return $responseData && is_array($responseData) ? $this->createResponse($this->pageId, $responseData) : null;
} catch (\Throwable $e) {
$this->logger->error('Failed to navigate to URL', [
'url' => $url,
'error' => $e->getMessage(),
'exception' => $e,
'url' => $logUrl,
'error' => Sanitizer::sanitizeParams($e->getMessage()),
'exceptionType' => $e::class,
]);
throw $e;
}
Expand Down Expand Up @@ -406,8 +412,8 @@ public function screenshot(?string $path = null, array|ScreenshotOptions $option
} catch (\Throwable $e) {
$this->logger->error('Failed to take screenshot', [
'path' => $finalPath,
'error' => $e->getMessage(),
'exception' => $e,
'error' => Sanitizer::sanitizeParams($e->getMessage()),
'exceptionType' => $e::class,
]);
throw $e;
}
Expand Down Expand Up @@ -485,8 +491,8 @@ public function pdf(?string $path = null, array|PdfOptions $options = []): strin
} catch (\Throwable $e) {
$this->logger->error('Failed to generate PDF', [
'path' => $finalPath,
'error' => $e->getMessage(),
'exception' => $e,
'error' => Sanitizer::sanitizeParams($e->getMessage()),
'exceptionType' => $e::class,
]);

throw $e;
Expand Down Expand Up @@ -832,7 +838,7 @@ private function sendCommand(string $action, array $params = []): array
$error = $response['error'];
if (!is_array($error)) {
$message = is_string($error) ? $error : 'Unknown error';
$this->logger->error('Playwright server error (non-structured)', ['error' => $error]);
$this->logger->error('Playwright server error (non-structured)', ['error' => Sanitizer::sanitizeParams($error)]);
if (str_contains($message, 'Timeout')) {
throw new TimeoutException($message);
}
Expand All @@ -847,7 +853,7 @@ private function sendCommand(string $action, array $params = []): array
$message = 'Unknown error';
}

$this->logger->error('Playwright server error', ['error' => $error]);
$this->logger->error('Playwright server error', ['error' => Sanitizer::sanitizeParams($error)]);

if (str_contains($message, 'Target page, context or browser has been closed')) {
throw new PlaywrightException('Browser context has been closed');
Expand Down Expand Up @@ -1220,12 +1226,12 @@ public function addStyleTag(array|StyleTagOptions $options): self

public function frameLocator(string $selector): FrameLocatorInterface
{
return new FrameLocator($this->transport, $this->pageId, $selector, null, $this);
return new FrameLocator($this->transport, $this->pageId, $selector, $this->logger, $this);
}

public function mainFrame(): FrameInterface
{
return new Frame($this->transport, $this->pageId, ':root', null, $this);
return new Frame($this->transport, $this->pageId, ':root', $this->logger, $this);
}

/**
Expand All @@ -1242,7 +1248,7 @@ public function frames(): array
$result = [];
foreach ($frames as $frameData) {
if (\is_array($frameData) && isset($frameData['selector']) && \is_string($frameData['selector'])) {
$result[] = new Frame($this->transport, $this->pageId, $frameData['selector'], null, $this);
$result[] = new Frame($this->transport, $this->pageId, $frameData['selector'], $this->logger, $this);
}
}

Expand All @@ -1258,7 +1264,7 @@ public function frame(array|FrameQueryOptions $options): ?FrameInterface
$response = $this->sendCommand('frame', ['options' => $options]);
$selector = $response['selector'] ?? null;
if (\is_string($selector)) {
return new Frame($this->transport, $this->pageId, $selector, null, $this);
return new Frame($this->transport, $this->pageId, $selector, $this->logger, $this);
}

return null;
Expand Down Expand Up @@ -1381,8 +1387,8 @@ public function setInputFiles(string $selector, array $files, array|SetInputFile
$this->logger->error('Failed to set input files', [
'selector' => $selector,
'files' => $files,
'error' => $e->getMessage(),
'exception' => $e,
'error' => Sanitizer::sanitizeParams($e->getMessage()),
'exceptionType' => $e::class,
]);
throw $e;
}
Expand Down
23 changes: 23 additions & 0 deletions tests/Unit/Locator/LocatorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
use Playwright\Locator\Options\SetCheckedOptions;
use Playwright\Locator\Options\TapOptions;
use Playwright\Page\PageInterface;
use Playwright\Tests\Mocks\TestLogger;
use Playwright\Transport\TransportInterface;

#[CoversClass(Locator::class)]
Expand Down Expand Up @@ -1255,6 +1256,28 @@ public function testGetFrameSelector(): void
* Rebinds $this->locator to the '.items' selector used by the filter and
* combinator tests, which need a different base selector than setUp().
*/
public function testFailedSetInputFilesLogsTheSanitizedErrorAndExceptionType(): void
{
$logger = new TestLogger();
$locator = new Locator($this->transport, 'page1', 'input[type=file]', null, $logger);
$failure = new PlaywrightException('Upload failed: Bearer example-upload-secret');
$this->transport->method('send')->willThrowException($failure);

try {
$locator->setInputFiles(__FILE__);
$this->fail('Expected the transport exception.');
} catch (PlaywrightException $exception) {
$this->assertSame($failure, $exception);
}

$errors = array_values(array_filter($logger->records, static fn (array $record): bool => 'error' === $record['level']));
$this->assertCount(1, $errors);
$this->assertSame('Failed to set input files on locator', $errors[0]['message']);
$this->assertSame('Upload failed: Bearer [REDACTED]', $errors[0]['context']['error']);
$this->assertSame(PlaywrightException::class, $errors[0]['context']['exceptionType']);
$this->assertArrayNotHasKey('exception', $errors[0]['context']);
}

private function useItemsLocator(): void
{
$this->locator = new Locator($this->transport, 'page1', '.items');
Expand Down
117 changes: 117 additions & 0 deletions tests/Unit/Page/PageTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
namespace Playwright\Tests\Unit\Page;

use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
Expand Down Expand Up @@ -43,6 +44,7 @@
use Playwright\Page\PageEventHandlerInterface;
use Playwright\Regex;
use Playwright\Screencast\ScreencastInterface;
use Playwright\Tests\Mocks\TestLogger;
use Playwright\Transport\TransportInterface;
use Playwright\Video\VideoInterface;
use Playwright\WebStorage\WebStorageInterface;
Expand All @@ -69,6 +71,121 @@ public function testGetKeyboard(): void
$this->assertInstanceOf(KeyboardInterface::class, $keyboard);
}

public function testNavigationUsesTheConfiguredLoggerWithoutExposingCredentials(): void
{
$logger = new TestLogger();
$page = new Page($this->transport, $this->context, 'page-id', new PlaywrightConfig(logger: $logger));
$url = 'https://example.test/?token=example-navigation-secret';
$this->transport->expects($this->once())->method('send')
->with($this->callback(static fn (array $message): bool => $url === $message['url']))
->willReturn([]);

$page->goto($url);

$this->assertTrue($logger->hasInfoThatContains('Successfully navigated'));
$log = json_encode($logger->records, JSON_THROW_ON_ERROR);
$this->assertStringContainsString('token=[REDACTED]', $log);
$this->assertStringNotContainsString('example-navigation-secret', $log);
}

#[DataProvider('locatorFactories')]
public function testLocatorsLogANativeFailureOnceWithTheConfiguredLogger(callable $createLocator): void
{
$logger = new TestLogger();
$page = new Page($this->transport, $this->context, 'page-id', new PlaywrightConfig(logger: $logger));
$message = 'Playwright action failed: Bearer example-locator-secret';
$this->transport->expects($this->once())->method('send')->willReturn(['error' => $message]);
$locator = $createLocator($page);

$this->expectException(PlaywrightException::class);
$this->expectExceptionMessage($message);

try {
$locator->click();
} finally {
$errors = array_values(array_filter($logger->records, static fn (array $record): bool => 'error' === $record['level']));
$this->assertCount(1, $errors);
$this->assertSame('Playwright action failed: Bearer [REDACTED]', $errors[0]['context']['error']);
}
}

#[DataProvider('failingFileOperations')]
public function testFailedFileOperationsLogTheSanitizedErrorAndExceptionType(callable $operation, string $logMessage): void
{
$logger = new TestLogger();
$page = new Page($this->transport, $this->context, 'page-id', new PlaywrightConfig(logger: $logger));
$failure = new PlaywrightException('Operation failed: Bearer example-file-secret');
$this->transport->method('send')->willThrowException($failure);

try {
$operation($page);
$this->fail('Expected the transport exception.');
} catch (PlaywrightException $exception) {
$this->assertSame($failure, $exception);
}

$errors = array_values(array_filter(
$logger->records,
static fn (array $record): bool => 'error' === $record['level'] && $logMessage === $record['message'],
));
$this->assertCount(1, $errors);
$this->assertSame('Operation failed: Bearer [REDACTED]', $errors[0]['context']['error']);
$this->assertSame(PlaywrightException::class, $errors[0]['context']['exceptionType']);
$this->assertArrayNotHasKey('exception', $errors[0]['context']);
$this->assertStringNotContainsString('example-file-secret', json_encode($logger->records, JSON_THROW_ON_ERROR));
}

public static function failingFileOperations(): iterable
{
yield 'screenshot' => [
static fn (Page $page) => $page->screenshot(sys_get_temp_dir().'/playwright-php-failed-screenshot.png'),
'Failed to take screenshot',
];
yield 'pdf' => [
static fn (Page $page) => $page->pdf(sys_get_temp_dir().'/playwright-php-failed.pdf'),
'Failed to generate PDF',
];
yield 'set input files' => [
static fn (Page $page) => $page->setInputFiles('input[type=file]', [__FILE__]),
'Failed to set input files',
];
}

public function testStructuredServerErrorIsLoggedSanitized(): void
{
$logger = new TestLogger();
$page = new Page($this->transport, $this->context, 'page-id', new PlaywrightConfig(logger: $logger));
$this->transport->method('send')->willReturn([
'error' => ['name' => 'Error', 'message' => 'Request failed: Bearer example-server-secret'],
]);

try {
$page->title();
$this->fail('Expected the server error.');
} catch (PlaywrightException $exception) {
$this->assertSame('Request failed: Bearer example-server-secret', $exception->getMessage());
}

$errors = array_values(array_filter($logger->records, static fn (array $record): bool => 'error' === $record['level']));
$this->assertCount(1, $errors);
$this->assertSame('Playwright server error', $errors[0]['message']);
$this->assertStringNotContainsString('example-server-secret', json_encode($logger->records, JSON_THROW_ON_ERROR));
}

public static function locatorFactories(): iterable
{
yield 'nested locator' => [static function (Page $page) {
$parent = $page->locator('section');

return $parent->locator('button');
}];
yield 'frame locator' => [static function (Page $page) {
$frame = $page->frameLocator('iframe');

return $frame->locator('button');
}];
}

public function testGetMouse(): void
{
$mouse = $this->page->mouse();
Expand Down
Loading