diff --git a/CHANGELOG.md b/CHANGELOG.md index 10ab2de..d7ba7a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/guide/advanced-recipes.md b/docs/guide/advanced-recipes.md index e0bde83..73da72f 100644 --- a/docs/guide/advanced-recipes.md +++ b/docs/guide/advanced-recipes.md @@ -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 @@ -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. diff --git a/src/Locator/Locator.php b/src/Locator/Locator.php index 1987171..4cdd738 100644 --- a/src/Locator/Locator.php +++ b/src/Locator/Locator.php @@ -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; @@ -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; } @@ -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); } diff --git a/src/Page/Page.php b/src/Page/Page.php index 3f0c0bd..3b68024 100644 --- a/src/Page/Page.php +++ b/src/Page/Page.php @@ -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; @@ -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; @@ -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->logger = $logger ?? $config->logger ?? new NullLogger(); + $this->keyboard = new Keyboard($this->transport, $this->pageId); $this->mouse = new Mouse($this->transport, $this->pageId); $this->touchscreen = new Touchscreen($this->transport, $this->pageId); @@ -262,7 +267,7 @@ public function locator(string $selector, array|LocatorOptions $options = []): L $this->pageId, $selector, null, - null, + $this->logger, $this->normalizeLocatorOptions($options), $this, ); @@ -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; } @@ -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; } @@ -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; @@ -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); } @@ -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'); @@ -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); } /** @@ -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); } } @@ -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; @@ -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; } diff --git a/tests/Unit/Locator/LocatorTest.php b/tests/Unit/Locator/LocatorTest.php index 69b6357..7abe50d 100644 --- a/tests/Unit/Locator/LocatorTest.php +++ b/tests/Unit/Locator/LocatorTest.php @@ -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)] @@ -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'); diff --git a/tests/Unit/Page/PageTest.php b/tests/Unit/Page/PageTest.php index b2bb4f3..c14e6e0 100644 --- a/tests/Unit/Page/PageTest.php +++ b/tests/Unit/Page/PageTest.php @@ -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; @@ -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; @@ -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();