Skip to content
Merged
27 changes: 27 additions & 0 deletions src/Playwright/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use Pest\Browser\Exceptions\PlaywrightOutdatedException;
use PHPUnit\Framework\ExpectationFailedException;
use RuntimeException;
use Throwable;

use function Amp\Websocket\Client\connect;

Expand Down Expand Up @@ -134,6 +135,32 @@ public function execute(string $guid, string $method, array $params = [], array
}
}

/**
* Drops the current connection so the next test can initialize again.
*
* Playwright accepts initialize only once per socket, and marks the
* socket initialized before the browser launch finishes.
*/
public function disconnect(): void
{
if (! $this->websocketConnection instanceof WebsocketConnection) {
return;
}

$connection = $this->websocketConnection;
$this->websocketConnection = null;

if ($connection->isClosed()) {
return;
}

try {
$connection->close();
} catch (Throwable) {
// The socket can already be gone when initialize fails.
}
}

/**
* Sets the timeout in milliseconds for requests.
*/
Expand Down
106 changes: 66 additions & 40 deletions src/Playwright/Playwright.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use Pest\Browser\Enums\BrowserType;
use Pest\Browser\Enums\ColorScheme;
use Throwable;

/**
* @internal
Expand All @@ -18,6 +19,14 @@ final class Playwright
*/
public const int DEFAULT_SLOW_MO = 500;

/**
* Timeout for the one-time browser launch, in milliseconds.
*
* Kept apart from the action timeout so a slow Chromium start does not
* fail the limit used for page actions.
*/
private const int LAUNCH_TIMEOUT = 30_000;

/**
* Browser types
*
Expand Down Expand Up @@ -307,49 +316,66 @@ public static function usingTimeout(int $timeout, callable $callback): mixed
}

/**
* Initialize Playwright
* Initializes Playwright on the current connection.
*
* Launch uses its own timeout, separate from the action timeout. A failed
* initialize drops the connection: Playwright accepts that call only once.
*/
private static function initialize(string $browser): BrowserFactory
{
$response = Client::instance()->execute(
'',
'initialize',
[
'sdkLanguage' => 'javascript',
]
);

/** @var array{method: string|null, params: array{type: string|null, guid: string, initializer: array{name: string|null, preLaunchedBrowser?: array{guid: string}}}} $message */
foreach ($response as $message) {
if (
isset($message['method'])
&& $message['method'] === '__create__'
&& isset($message['params']['type'])
&& $message['params']['type'] === 'Playwright'
&& isset($message['params']['initializer']['preLaunchedBrowser'])
) {
self::$browserTypes[$browser]->prelaunch(
$message['params']['initializer']['preLaunchedBrowser']['guid'],
);
}

if (
isset($message['method'])
&& $message['method'] === '__create__'
&& isset($message['params']['type'])
&& $message['params']['type'] === 'BrowserType'
) {
$name = $message['params']['initializer']['name'] ?? '';

self::$browserTypes[$name] = new BrowserFactory(
$message['params']['guid'],
$name,
self::$headless,
self::$userAgent
);
}
}
$client = Client::instance();

return self::$browserTypes[$browser];
try {
return self::usingTimeout(
max($client->timeout(), self::LAUNCH_TIMEOUT),
function () use ($browser, $client): BrowserFactory {
$response = $client->execute(
'',
'initialize',
[
'sdkLanguage' => 'javascript',
]
);

/** @var array{method: string|null, params: array{type: string|null, guid: string, initializer: array{name: string|null, preLaunchedBrowser?: array{guid: string}}}} $message */
foreach ($response as $message) {
if (
isset($message['method'])
&& $message['method'] === '__create__'
&& isset($message['params']['type'])
&& $message['params']['type'] === 'Playwright'
&& isset($message['params']['initializer']['preLaunchedBrowser'])
) {
self::$browserTypes[$browser]->prelaunch(
$message['params']['initializer']['preLaunchedBrowser']['guid'],
);
}

if (
isset($message['method'])
&& $message['method'] === '__create__'
&& isset($message['params']['type'])
&& $message['params']['type'] === 'BrowserType'
) {
$name = $message['params']['initializer']['name'] ?? '';

self::$browserTypes[$name] = new BrowserFactory(
$message['params']['guid'],
$name,
self::$headless,
self::$userAgent
);
}
}

return self::$browserTypes[$browser];
}
);
} catch (Throwable $throwable) {
self::$browserTypes = [];
$client->disconnect();

throw $throwable;
}
}
}
30 changes: 30 additions & 0 deletions tests/Browser/Visit/InitializeRecoveryTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

declare(strict_types=1);

use Illuminate\Support\Facades\Route;
use Pest\Browser\Enums\BrowserType;
use Pest\Browser\Playwright\Client;
use Pest\Browser\Playwright\Playwright;
use PHPUnit\Framework\ExpectationFailedException;

it('can open a page after initialization fails', function (): void {
Route::get('/', fn (): string => '<div>Welcome</div>');

$timeout = Client::instance()->timeout();

Playwright::browser(BrowserType::CHROME);

expect(Client::instance()->timeout())->toBe($timeout);

new ReflectionProperty(Playwright::class, 'browserTypes')->setValue([]);

expect(fn (): Pest\Browser\Playwright\BrowserFactory => Playwright::browser(BrowserType::CHROME))
->toThrow(ExpectationFailedException::class, 'Assertion error');

expect(Client::instance()->timeout())->toBe($timeout);

$this->__markAsBrowserTest();

visit('/')->assertSee('Welcome');
});
110 changes: 110 additions & 0 deletions tests/Unit/Playwright/InitializationTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
<?php

declare(strict_types=1);

use Amp\Websocket\Client\WebsocketConnection;
use Amp\Websocket\WebsocketMessage;
use Pest\Browser\Enums\BrowserType;
use Pest\Browser\Playwright\BrowserFactory;
use Pest\Browser\Playwright\Client;
use Pest\Browser\Playwright\Playwright;
use PHPUnit\Framework\ExpectationFailedException;

it('uses a launch timeout without changing the action timeout', function (int $actionTimeout, int $launchTimeout): void {
$instance = new ReflectionProperty(Client::class, 'instance');
$browserTypes = new ReflectionProperty(Playwright::class, 'browserTypes');
$previousClient = $instance->getValue();
$previousBrowserTypes = $browserTypes->getValue();
$requests = [];
$responses = [];

$connection = $this->createStub(WebsocketConnection::class);
$connection->method('sendText')->willReturnCallback(function (string $json) use (&$requests, &$responses): void {
$request = json_decode($json, true);
$requests[] = $request;

if ($request['method'] === 'initialize') {
$responses[] = ['method' => '__create__', 'params' => [
'type' => 'BrowserType',
'guid' => 'browserType@chromium',
'initializer' => ['name' => 'chromium'],
]];
}

$responses[] = ['id' => $request['id'], 'result' => []];
});
$connection->method('receive')->willReturnCallback(
function () use (&$responses): WebsocketMessage {
return WebsocketMessage::fromText((string) json_encode(array_shift($responses)));
},
);

$client = new Client();
$client->setTimeout($actionTimeout);
new ReflectionProperty(Client::class, 'websocketConnection')->setValue($client, $connection);

try {
$instance->setValue($client);
$browserTypes->setValue([]);

expect(Playwright::browser(BrowserType::CHROME))->toBeInstanceOf(BrowserFactory::class);

iterator_to_array($client->execute('page@1', 'goto'));

expect($requests[0]['metadata']['timeout'])->toBe($launchTimeout)
->and($requests[0]['params']['timeout'])->toBe($launchTimeout)
->and($requests[1]['metadata']['timeout'])->toBe($actionTimeout)
->and($requests[1]['params']['timeout'])->toBe($actionTimeout)
->and($client->timeout())->toBe($actionTimeout);
} finally {
$instance->setValue($previousClient);
$browserTypes->setValue($previousBrowserTypes);
}
})->with([
'short action timeout' => [2_000, 30_000],
'longer configured timeout' => [45_000, 45_000],
]);

it('discards partial initialization and closes the failed connection', function (): void {
$instance = new ReflectionProperty(Client::class, 'instance');
$browserTypes = new ReflectionProperty(Playwright::class, 'browserTypes');
$previousClient = $instance->getValue();
$previousBrowserTypes = $browserTypes->getValue();
$responses = [
['method' => '__create__', 'params' => [
'type' => 'BrowserType',
'guid' => 'browserType@chromium',
'initializer' => ['name' => 'chromium'],
]],
['error' => ['error' => ['message' => 'Timeout 30000ms exceeded.']]],
];

$connection = $this->createMock(WebsocketConnection::class);
$connection->method('receive')->willReturnCallback(
function () use (&$responses): WebsocketMessage {
return WebsocketMessage::fromText((string) json_encode(array_shift($responses)));
},
);
$connection->method('isClosed')->willReturn(false);
$connection->expects($this->once())->method('close');

$client = new Client();
$client->setTimeout(2_000);
$websocket = new ReflectionProperty(Client::class, 'websocketConnection');
$websocket->setValue($client, $connection);

try {
$instance->setValue($client);
$browserTypes->setValue([]);

expect(fn (): BrowserFactory => Playwright::browser(BrowserType::CHROME))
->toThrow(ExpectationFailedException::class, 'Timeout 30000ms exceeded.');

expect($browserTypes->getValue())->toBe([])
->and($websocket->getValue($client))->toBeNull()
->and($client->timeout())->toBe(2_000);
} finally {
$instance->setValue($previousClient);
$browserTypes->setValue($previousBrowserTypes);
}
});
Loading