diff --git a/src/Playwright/Client.php b/src/Playwright/Client.php index 8d81e4ae..6a60ee0f 100644 --- a/src/Playwright/Client.php +++ b/src/Playwright/Client.php @@ -10,6 +10,7 @@ use Pest\Browser\Exceptions\PlaywrightOutdatedException; use PHPUnit\Framework\ExpectationFailedException; use RuntimeException; +use Throwable; use function Amp\Websocket\Client\connect; @@ -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. */ diff --git a/src/Playwright/Playwright.php b/src/Playwright/Playwright.php index 99325098..e0be0dd0 100644 --- a/src/Playwright/Playwright.php +++ b/src/Playwright/Playwright.php @@ -6,6 +6,7 @@ use Pest\Browser\Enums\BrowserType; use Pest\Browser\Enums\ColorScheme; +use Throwable; /** * @internal @@ -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 * @@ -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; + } } } diff --git a/tests/Browser/Visit/InitializeRecoveryTest.php b/tests/Browser/Visit/InitializeRecoveryTest.php new file mode 100644 index 00000000..53027e14 --- /dev/null +++ b/tests/Browser/Visit/InitializeRecoveryTest.php @@ -0,0 +1,30 @@ + '
Welcome
'); + + $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'); +}); diff --git a/tests/Unit/Playwright/InitializationTest.php b/tests/Unit/Playwright/InitializationTest.php new file mode 100644 index 00000000..8fac014c --- /dev/null +++ b/tests/Unit/Playwright/InitializationTest.php @@ -0,0 +1,110 @@ +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); + } +});