Skip to content
Open
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
1 change: 1 addition & 0 deletions bin/lib/core.js
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ class ErrorHandler {
return {
requestId,
error: message,
errorName: error.name,
stack: process.env.PLAYWRIGHT_DEBUG === 'true' ? error.stack : undefined,
command: command?.action,
};
Expand Down
4 changes: 2 additions & 2 deletions bin/lib/handlers.js
Original file line number Diff line number Diff line change
Expand Up @@ -358,7 +358,7 @@ class ContextHandler extends BaseHandler {
requestId,
error: error.message
});
return { popupPageId: null };
throw error;
}
}
}
Expand Down Expand Up @@ -726,7 +726,7 @@ class PageHandler extends BaseHandler {
requestId,
error: error.message
});
return { popupPageId: null };
throw error;
}
}
}
Expand Down
6 changes: 5 additions & 1 deletion bin/lib/popup-coordinator.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ class PopupCoordinator {
? src.waitForEvent('popup', { timeout })
: src.waitForEvent('page', { timeout });

// PHP may still be running the action when this rejects. Observe
// it now; awaiting the original promise below still throws it.
popupPromise.catch(() => {});

return {
popupPromise,
listenerId: generateId(isPage ? 'popup_listener' : 'context_popup_listener'),
Expand Down Expand Up @@ -65,7 +69,7 @@ class PopupCoordinator {
return { popupPageId, popup };
} catch (error) {
logger.error(`${isPage ? 'Popup' : 'Context popup'} wait failed`, { requestId, error: error.message });
return { popupPageId: null };
throw error;
}
},
waitForCallback: false
Expand Down
2 changes: 1 addition & 1 deletion bin/playwright-server.js
Original file line number Diff line number Diff line change
Expand Up @@ -345,7 +345,7 @@ class PlaywrightServer extends BaseHandler {

} catch (error) {
logger.error('Callback continuation failed', { requestId, error: error.message });
return { error: error.message };
throw error;
}
}

Expand Down
53 changes: 33 additions & 20 deletions src/Transport/JsonRpc/JsonRpcTransport.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

use Playwright\Event\EventDispatcherInterface;
use Playwright\Exception\NetworkException;
use Playwright\Transport\ErrorMapper;
use Playwright\Transport\TransportInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
Expand Down Expand Up @@ -376,34 +377,46 @@ private function handleCallbackCommand(array $message, ?int $timeoutMs): array
throw new NetworkException('JSON-RPC client not available');
}
$client = $this->client;
$response = $client->sendRaw($message, $timeoutMs);

$this->logger->debug('Callback command response received', [
'requestId' => $requestId,
'response' => $response,
]);
try {
$response = $client->sendRaw($message, $timeoutMs);

if (isset($response['type']) && 'callback' === $response['type']) {
$this->logger->info('Server requested callback', [
$this->logger->debug('Callback command response received', [
'requestId' => $requestId,
'callbackType' => $response['callbackType'] ?? 'unknown',
'response' => $response,
]);

$this->executeCallback($response);
if (isset($response['type']) && 'callback' === $response['type']) {
$this->logger->info('Server requested callback', [
'requestId' => $requestId,
'callbackType' => $response['callbackType'] ?? 'unknown',
]);

$continueMessage = [
'action' => 'callback.continue',
'requestId' => $requestId,
'callbackResult' => ['executed' => true],
];
$this->executeCallback($response);

$finalResponse = $client->sendRaw($continueMessage, $timeoutMs);
unset($this->pendingCallbacks[$requestId]);
$continueMessage = [
'action' => 'callback.continue',
'requestId' => $requestId,
'callbackResult' => ['executed' => true],
];

return $finalResponse;
}
$response = $client->sendRaw($continueMessage, $timeoutMs);
}

return $response;
if (isset($response['error'])) {
$error = $response['error'];
$errorData = [
'name' => is_array($error) ? ($error['name'] ?? null) : ($response['errorName'] ?? null),
'message' => is_array($error) ? ($error['message'] ?? null) : $error,
'stack' => is_array($error) ? ($error['stack'] ?? null) : ($response['stack'] ?? null),
'code' => is_array($error) ? ($error['code'] ?? null) : null,
];
throw ErrorMapper::toException($errorData, is_string($message['action'] ?? null) ? $message['action'] : null, $message, $timeoutMs);
}

return $response;
} finally {
unset($this->pendingCallbacks[$requestId]);
}
}

/**
Expand Down
94 changes: 94 additions & 0 deletions tests/Integration/Popup/PopupFailureTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
<?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\Integration\Popup;

use PHPUnit\Framework\Attributes\CoversNothing;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Playwright\Exception\PlaywrightException;
use Playwright\Exception\TimeoutException;
use Playwright\Testing\PlaywrightTestCaseTrait;

#[CoversNothing]
final class PopupFailureTest extends TestCase
{
use PlaywrightTestCaseTrait;

protected function setUp(): void
{
$this->setUpPlaywright();
}

protected function tearDown(): void
{
$this->closeSharedPlaywright();
}

/** @return iterable<string, array{bool}> */
public static function popupOwners(): iterable
{
yield 'page' => [false];
yield 'context' => [true];
}

#[DataProvider('popupOwners')]
public function testClosingTheOwnerPreservesTheFailure(bool $useContext): void
{
$context = $this->browser->newContext();
$owner = $useContext ? $context : $context->newPage();

try {
$owner->waitForPopup(static function () use ($owner): void {
$owner->close();
// The wait rejects before PHP sends the callback continuation.
usleep(50000);
}, ['timeout' => 1000]);
$this->fail('Closing the popup owner must fail the wait.');
} catch (PlaywrightException $e) {
$this->assertNotInstanceOf(TimeoutException::class, $e);
$this->assertStringContainsString('closed', $e->getMessage());
}

$this->assertSame('about:blank', $this->page->url());
}

#[DataProvider('popupOwners')]
public function testTimeoutPreservesTheNativeDetails(bool $useContext): void
{
$owner = $useContext ? $this->context : $this->page;

$this->expectException(TimeoutException::class);
$this->expectExceptionMessage('Timeout 100ms exceeded');

$owner->waitForPopup(static function (): void {}, ['timeout' => 100]);
}

#[DataProvider('popupOwners')]
public function testTimeoutBeforeCallbackReturnsDoesNotCrashTheBridge(bool $useContext): void
{
$owner = $useContext ? $this->context : $this->page;

try {
$owner->waitForPopup(static function (): void {
usleep(200000);
}, ['timeout' => 50]);
$this->fail('The popup wait must time out.');
} catch (TimeoutException $e) {
$this->assertStringContainsString('Timeout 50ms exceeded', $e->getMessage());
}

$this->assertSame('about:blank', $this->page->url());
}
}
67 changes: 67 additions & 0 deletions tests/Unit/Transport/JsonRpc/JsonRpcTransportTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@

use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
use Playwright\Exception\DisconnectedException;
use Playwright\Exception\NetworkException;
use Playwright\Exception\TimeoutException;
use Playwright\Tests\Mocks\TestLogger;
use Playwright\Transport\JsonRpc\JsonRpcClient;
use Playwright\Transport\JsonRpc\JsonRpcTransport;
Expand Down Expand Up @@ -248,6 +250,71 @@ private function injectClient(JsonRpcTransport $transport, JsonRpcClient $client
(new \ReflectionProperty($transport, 'client'))->setValue($transport, $client);
}

public function testCallbackSetupFailurePreservesTheErrorAndReleasesTheCallback(): void
{
$transport = $this->createConnectedTransport(['command' => ['node', 'server.js']]);
$transport->storePendingCallback('popup', fn () => $this->fail('The action must not run after setup fails.'));
$client = $this->createMock(JsonRpcClient::class);
$client->expects($this->once())->method('sendRaw')->willReturn([
'error' => ['name' => 'TargetClosedError', 'message' => 'The popup owner has closed'],
]);
$this->injectClient($transport, $client);

try {
$transport->send(['action' => 'page.waitForPopup', 'requestId' => 'popup']);
$this->fail('Expected the setup error.');
} catch (DisconnectedException $e) {
$this->assertSame('The popup owner has closed', $e->getMessage());
$this->assertSame('page.waitForPopup', $e->getContext()['method']);
}

$this->assertSame([], (new \ReflectionProperty($transport, 'pendingCallbacks'))->getValue($transport));
}

public function testCallbackContinuationPreservesTheTimeoutAndReleasesTheCallback(): void
{
$transport = $this->createConnectedTransport(['command' => ['node', 'server.js']]);
$executed = false;
$transport->storePendingCallback('popup', static function () use (&$executed): void { $executed = true; });
$client = $this->createMock(JsonRpcClient::class);
$client->expects($this->exactly(2))->method('sendRaw')->willReturnOnConsecutiveCalls(
['type' => 'callback', 'callbackType' => 'readyForAction', 'requestId' => 'popup'],
['error' => 'Timeout 100ms exceeded while waiting for popup', 'errorName' => 'TimeoutError'],
);
$this->injectClient($transport, $client);

try {
$transport->send(['action' => 'context.waitForPopup', 'requestId' => 'popup']);
$this->fail('Expected the native timeout.');
} catch (TimeoutException $e) {
$this->assertSame('Timeout 100ms exceeded while waiting for popup', $e->getMessage());
}

$this->assertTrue($executed);
$this->assertSame([], (new \ReflectionProperty($transport, 'pendingCallbacks'))->getValue($transport));
}

public function testThrowingCallbackIsReleasedAndItsExceptionIsPreserved(): void
{
$transport = $this->createConnectedTransport(['command' => ['node', 'server.js']]);
$failure = new \RuntimeException('Action failed');
$transport->storePendingCallback('popup', static fn () => throw $failure);
$client = $this->createMock(JsonRpcClient::class);
$client->expects($this->once())->method('sendRaw')->willReturn([
'type' => 'callback', 'callbackType' => 'readyForAction', 'requestId' => 'popup',
]);
$this->injectClient($transport, $client);

try {
$transport->send(['action' => 'page.waitForPopup', 'requestId' => 'popup']);
$this->fail('Expected the action failure.');
} catch (\RuntimeException $e) {
$this->assertSame($failure, $e);
}

$this->assertSame([], (new \ReflectionProperty($transport, 'pendingCallbacks'))->getValue($transport));
}

public function testSendPassesTheConfiguredTimeoutPerRequest(): void
{
$transport = $this->createConnectedTransport(['command' => ['node', 'server.js'], 'timeout' => 45]);
Expand Down
Loading