diff --git a/src/Transport/MockTransport.php b/src/Transport/MockTransport.php new file mode 100644 index 0000000..ff7ad98 --- /dev/null +++ b/src/Transport/MockTransport.php @@ -0,0 +1,325 @@ +, self): bool)|callable|null, + * payload: array|callable|\Throwable + * }> + */ + private array $scriptedResponses = []; + + /** @var array> */ + private array $sentMessages = []; + + /** @var array> */ + private array $asyncMessages = []; + + /** + * @var list, self): void> + */ + private array $asyncHandlers = []; + + /** + * @var list + */ + private array $eventCallbacks = []; + + /** @var list */ + private array $processedEventResults = []; + + /** @var array */ + private array $pendingRequestCallbacks = []; + + public function connect(): void + { + $this->connected = true; + } + + public function disconnect(): void + { + $this->connected = false; + } + + /** + * @param array $message + * + * @return array + */ + public function send(array $message): array + { + $this->ensureConnected(); + $this->sentMessages[] = $message; + + foreach ($this->scriptedResponses as $index => $script) { + if (null !== $script['matcher'] && !$this->invokeMatcher($script['matcher'], $message)) { + continue; + } + + unset($this->scriptedResponses[$index]); + $this->scriptedResponses = array_values($this->scriptedResponses); + + return $this->resolvePayload($script['payload'], $message); + } + + throw new \UnderflowException('No scripted response matches message '.$this->describeMessage($message)); + } + + /** + * @param array $message + */ + public function sendAsync(array $message): void + { + $this->ensureConnected(); + $this->asyncMessages[] = $message; + + foreach ($this->asyncHandlers as $handler) { + $this->invokeCallable($handler, $message); + } + } + + public function isConnected(): bool + { + return $this->connected; + } + + public function processEvents(): void + { + $this->ensureConnected(); + + while ($event = array_shift($this->eventCallbacks)) { + $this->processedEventResults[] = $this->invokeEventCallback($event); + } + } + + /** + * Queue a scripted response for the next {@see send()} call. + * + * @param array|callable|\Throwable $payload + * @param callable|null $matcher accepts signatures with zero, one (message) or two (message, transport) parameters + */ + public function queueResponse(array|callable|\Throwable $payload, ?callable $matcher = null): void + { + $this->scriptedResponses[] = [ + 'matcher' => $matcher, + 'payload' => $payload, + ]; + } + + /** + * Register a callback invoked on every {@see sendAsync()} call. + * + * @param callable $handler accepts signatures with zero, one (message), or two (message, transport) parameters + */ + public function onSendAsync(callable $handler): void + { + $this->asyncHandlers[] = $handler; + } + + /** + * Queue an event callback executed from {@see processEvents()}. + * + * @param callable $callback accepts signatures with zero parameters or one (transport) parameter + */ + public function queueProcessEvent(callable $callback): void + { + $this->eventCallbacks[] = $callback; + } + + /** + * Reset recorded history while keeping scripted responses and queued events. + */ + public function resetHistory(): void + { + $this->sentMessages = []; + $this->asyncMessages = []; + $this->processedEventResults = []; + } + + public function storePendingCallback(string $requestId, callable $callback): void + { + $this->pendingRequestCallbacks[$requestId] = $callback; + } + + /** + * @return array + */ + public function getStoredPendingCallbacks(): array + { + return $this->pendingRequestCallbacks; + } + + /** + * @param array $message + */ + public function executePendingCallback(string $requestId, array $message = []): mixed + { + if (!array_key_exists($requestId, $this->pendingRequestCallbacks)) { + throw new \OutOfBoundsException(sprintf('No pending callback stored for request "%s"', $requestId)); + } + + return $this->invokeCallable($this->pendingRequestCallbacks[$requestId], $message); + } + + /** + * @return list> + */ + public function getSentMessages(): array + { + return array_values($this->sentMessages); + } + + /** + * @return list> + */ + public function getAsyncMessages(): array + { + return array_values($this->asyncMessages); + } + + /** + * @return list + */ + public function getProcessedEventResults(): array + { + return $this->processedEventResults; + } + + public function getPendingResponseCount(): int + { + return count($this->scriptedResponses); + } + + public function getPendingEventCount(): int + { + return count($this->eventCallbacks); + } + + /** + * @param array $message + */ + private function invokeMatcher(callable $matcher, array $message): bool + { + $result = $this->invokeCallable($matcher, $message); + + if (!is_bool($result)) { + throw new \UnexpectedValueException('Mock transport matcher must return a boolean.'); + } + + return $result; + } + + /** + * @param array|callable|\Throwable $payload + * @param array $message + * + * @return array + */ + private function resolvePayload(array|callable|\Throwable $payload, array $message): array + { + if ($payload instanceof \Throwable) { + throw $payload; + } + + if (is_callable($payload)) { + $result = $this->invokeCallable($payload, $message); + + if (!is_array($result)) { + throw new \UnexpectedValueException('Mock transport response callback must return an array.'); + } + + return $this->sanitizeAssociativeArray($result); + } + + return $this->sanitizeAssociativeArray($payload); + } + + /** + * @param array $message + */ + private function invokeCallable(callable $callable, array $message): mixed + { + $closure = $callable instanceof \Closure ? $callable : \Closure::fromCallable($callable); + $reflection = new \ReflectionFunction($closure); + $parameterCount = $reflection->getNumberOfParameters(); + + return match (true) { + $parameterCount >= 2 => $closure($message, $this), + 1 === $parameterCount => $closure($message), + default => $closure(), + }; + } + + private function invokeEventCallback(callable $callback): mixed + { + $closure = $callback instanceof \Closure ? $callback : \Closure::fromCallable($callback); + $reflection = new \ReflectionFunction($closure); + + return $reflection->getNumberOfParameters() >= 1 ? $closure($this) : $closure(); + } + + /** + * @param array $message + */ + private function describeMessage(array $message): string + { + $action = $message['action'] ?? null; + if (is_string($action) && '' !== $action) { + return sprintf('[action=%s]', $action); + } + + $encoded = json_encode($message); + if (false === $encoded) { + return '[unserializable-message]'; + } + + return $encoded; + } + + private function ensureConnected(): void + { + if (!$this->connected) { + throw new \UnexpectedValueException('Mock transport is not connected.'); + } + } + + /** + * @param array $payload + * + * @return array + */ + private function sanitizeAssociativeArray(array $payload): array + { + $result = []; + foreach ($payload as $key => $value) { + if (is_string($key)) { + $result[$key] = $value; + } + } + + return $result; + } +} diff --git a/src/Transport/TraceableTransport.php b/src/Transport/TraceableTransport.php new file mode 100644 index 0000000..104c00e --- /dev/null +++ b/src/Transport/TraceableTransport.php @@ -0,0 +1,219 @@ +, + * response: array|null, + * exception: \Throwable|null + * }> + */ + private array $sendCalls = []; + + /** + * @var list, + * exception: \Throwable|null + * }> + */ + private array $asyncCalls = []; + + /** + * @var list + */ + private array $processEventsCalls = []; + + /** + * @var list + */ + private array $connectionCalls = []; + + public function __construct(private readonly TransportInterface $decorated) + { + } + + public function connect(): void + { + $this->recordConnection('connect', function (): void { + $this->decorated->connect(); + }); + } + + public function disconnect(): void + { + $this->recordConnection('disconnect', function (): void { + $this->decorated->disconnect(); + }); + } + + /** + * @param array $message + * + * @return array + */ + public function send(array $message): array + { + try { + $response = $this->decorated->send($message); + $this->sendCalls[] = [ + 'message' => $message, + 'response' => $response, + 'exception' => null, + ]; + + return $response; + } catch (\Throwable $exception) { + $this->sendCalls[] = [ + 'message' => $message, + 'response' => null, + 'exception' => $exception, + ]; + + throw $exception; + } + } + + /** + * @param array $message + */ + public function sendAsync(array $message): void + { + try { + $this->decorated->sendAsync($message); + $this->asyncCalls[] = [ + 'message' => $message, + 'exception' => null, + ]; + } catch (\Throwable $exception) { + $this->asyncCalls[] = [ + 'message' => $message, + 'exception' => $exception, + ]; + + throw $exception; + } + } + + public function isConnected(): bool + { + return $this->decorated->isConnected(); + } + + public function processEvents(): void + { + try { + $this->decorated->processEvents(); + $this->processEventsCalls[] = [ + 'exception' => null, + ]; + } catch (\Throwable $exception) { + $this->processEventsCalls[] = [ + 'exception' => $exception, + ]; + + throw $exception; + } + } + + /** + * Reset all recorded call history. + */ + public function reset(): void + { + $this->sendCalls = []; + $this->asyncCalls = []; + $this->processEventsCalls = []; + $this->connectionCalls = []; + } + + /** + * @return list, + * response: array|null, + * exception: \Throwable|null + * }> + */ + public function getSendCalls(): array + { + return $this->sendCalls; + } + + /** + * @return list, + * exception: \Throwable|null + * }> + */ + public function getAsyncCalls(): array + { + return $this->asyncCalls; + } + + /** + * @return list + */ + public function getProcessEventsCalls(): array + { + return $this->processEventsCalls; + } + + /** + * @return list + */ + public function getConnectionCalls(): array + { + return $this->connectionCalls; + } + + /** + * @param 'connect'|'disconnect' $method + * @param callable(): void $callback + */ + private function recordConnection(string $method, callable $callback): void + { + try { + $callback(); + $this->connectionCalls[] = [ + 'method' => $method, + 'exception' => null, + ]; + } catch (\Throwable $exception) { + $this->connectionCalls[] = [ + 'method' => $method, + 'exception' => $exception, + ]; + + throw $exception; + } + } +} diff --git a/tests/Unit/Browser/BrowserContextPopupTest.php b/tests/Unit/Browser/BrowserContextPopupTest.php index 341765b..150c138 100644 --- a/tests/Unit/Browser/BrowserContextPopupTest.php +++ b/tests/Unit/Browser/BrowserContextPopupTest.php @@ -19,57 +19,23 @@ use Playwright\Browser\BrowserContext; use Playwright\Configuration\PlaywrightConfig; use Playwright\Page\PageInterface; -use Playwright\Transport\TransportInterface; +use Playwright\Transport\MockTransport; +use Playwright\Transport\TraceableTransport; #[CoversClass(BrowserContext::class)] final class BrowserContextPopupTest extends TestCase { public function testWaitForPopupWithCallbackCoordinatedTransport(): void { - $transport = new class implements TransportInterface { - public bool $stored = false; - public $storedCallback; // property type callable not allowed in all PHP versions - public array $lastMessage = []; - - public function connect(): void - { - } - - public function disconnect(): void - { - } - - public function isConnected(): bool - { - return true; - } - - public function processEvents(): void - { - } - - public function sendAsync(array $message): void - { - } - - // Extra method detected by method_exists in BrowserContext - public function storePendingCallback(string $requestId, callable $callback): void - { - $this->stored = true; - $this->storedCallback = $callback; - } - - public function send(array $message): array - { - $this->lastMessage = $message; - // Simulate server returning a popup page identifier - if (($message['action'] ?? '') === 'context.waitForPopup') { - return ['popupPageId' => 'popup_ctx_123']; - } - - return []; - } - }; + $transport = new MockTransport(); + $transport->connect(); + + $lastMessage = null; + $transport->queueResponse(function (array $message) use (&$lastMessage): array { + $lastMessage = $message; + + return ['popupPageId' => 'popup_ctx_123']; + }); $context = new BrowserContext($transport, 'ctx_1', new PlaywrightConfig()); @@ -78,12 +44,12 @@ public function send(array $message): array $actionExecuted = true; // should not run immediately in coordinated path }); - // Transport was asked to store callback - $this->assertTrue($transport->stored, 'Expected storePendingCallback to be called'); + $stored = $transport->getStoredPendingCallbacks(); + $this->assertCount(1, $stored); - // Correct server action was sent - $this->assertSame('context.waitForPopup', $transport->lastMessage['action'] ?? null); - $this->assertSame('ctx_1', $transport->lastMessage['contextId'] ?? null); + $this->assertSame('context.waitForPopup', $lastMessage['action'] ?? null); + $this->assertSame('ctx_1', $lastMessage['contextId'] ?? null); + $this->assertArrayHasKey($lastMessage['requestId'] ?? '', $stored); // The action should not have executed immediately here (it would be executed by transport when server requests it) $this->assertFalse($actionExecuted, 'Callback should be deferred in coordinated path'); @@ -93,41 +59,17 @@ public function send(array $message): array public function testWaitForPopupFallbackWithoutCallbackSupport(): void { - // A plain mock transport without storePendingCallback method triggers fallback execution of the action - $transport = new class implements TransportInterface { - public array $lastMessage = []; - - public function connect(): void - { - } - - public function disconnect(): void - { - } - - public function isConnected(): bool - { - return true; - } - - public function processEvents(): void - { - } - - public function sendAsync(array $message): void - { - } - - public function send(array $message): array - { - $this->lastMessage = $message; - if (($message['action'] ?? '') === 'context.waitForPopup') { - return ['popupPageId' => 'popup_ctx_456']; - } - - return []; - } - }; + $inner = new MockTransport(); + $inner->connect(); + $lastMessage = null; + $inner->queueResponse(function (array $message) use (&$lastMessage): array { + $lastMessage = $message; + + return ['popupPageId' => 'popup_ctx_456']; + }); + + $transport = new TraceableTransport($inner); + $transport->connect(); $context = new BrowserContext($transport, 'ctx_2', new PlaywrightConfig()); @@ -139,43 +81,19 @@ public function send(array $message): array // Fallback should have executed the action synchronously $this->assertTrue($actionExecuted, 'Callback should execute immediately in fallback path'); - // Correct server action was sent - $this->assertSame('context.waitForPopup', $transport->lastMessage['action'] ?? null); - $this->assertSame('ctx_2', $transport->lastMessage['contextId'] ?? null); + $sendCalls = $transport->getSendCalls(); + $this->assertSame('context.waitForPopup', $sendCalls[0]['message']['action'] ?? null); + $this->assertSame('ctx_2', $sendCalls[0]['message']['contextId'] ?? null); + $this->assertSame($lastMessage, $sendCalls[0]['message']); $this->assertInstanceOf(PageInterface::class, $popup); } public function testWaitForPopupThrowsOnInvalidResponse(): void { - $transport = new class implements TransportInterface { - public function connect(): void - { - } - - public function disconnect(): void - { - } - - public function isConnected(): bool - { - return true; - } - - public function processEvents(): void - { - } - - public function sendAsync(array $message): void - { - } - - public function send(array $message): array - { - // Return an invalid response (missing popupPageId) - return ['popupPageId' => null]; - } - }; + $transport = new MockTransport(); + $transport->connect(); + $transport->queueResponse(static fn (): array => ['popupPageId' => null]); $context = new BrowserContext($transport, 'ctx_3', new PlaywrightConfig()); diff --git a/tests/Unit/Screenshot/ScreenshotHelperTest.php b/tests/Unit/Screenshot/ScreenshotHelperTest.php index 3c79194..5210773 100644 --- a/tests/Unit/Screenshot/ScreenshotHelperTest.php +++ b/tests/Unit/Screenshot/ScreenshotHelperTest.php @@ -123,9 +123,13 @@ public function testGetDirectoryInfoEmpty(): void public function testGetDirectoryInfoWithFiles(): void { - file_put_contents($this->testDir.'/old.png', 'fake png data 1'); - sleep(1); - file_put_contents($this->testDir.'/new.png', 'fake png data 2 longer'); + $oldPath = $this->testDir.'/old.png'; + $newPath = $this->testDir.'/new.png'; + + file_put_contents($oldPath, 'fake png data 1'); + file_put_contents($newPath, 'fake png data 2 longer'); + touch($oldPath, time() - 10); + touch($newPath, time()); file_put_contents($this->testDir.'/not-png.txt', 'should be ignored'); $info = ScreenshotHelper::getDirectoryInfo($this->testDir); diff --git a/tests/Unit/Transport/MockTransportTest.php b/tests/Unit/Transport/MockTransportTest.php new file mode 100644 index 0000000..a122d5d --- /dev/null +++ b/tests/Unit/Transport/MockTransportTest.php @@ -0,0 +1,251 @@ +expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('Mock transport is not connected.'); + + $transport->send(['action' => 'not.connected']); + } + + public function testSendReturnsQueuedResponse(): void + { + $transport = new MockTransport(); + $transport->queueResponse(['result' => 'ok']); + $transport->connect(); + + $this->assertTrue($transport->isConnected()); + + $response = $transport->send(['action' => 'browserType.launch']); + + $this->assertSame(['result' => 'ok'], $response); + $this->assertSame([['action' => 'browserType.launch']], $transport->getSentMessages()); + $this->assertSame(0, $transport->getPendingResponseCount()); + } + + public function testSendUsesMatcherAndCallback(): void + { + $transport = new MockTransport(); + $transport->queueResponse( + static fn (): array => ['result' => 'first'], + ); + $transport->queueResponse( + static fn (array $message): array => ['result' => $message['payload']], + static fn (array $message): bool => 'target' === ($message['action'] ?? null), + ); + $transport->queueResponse( + ['result' => 'fallback'], + static fn (): bool => true, + ); + $transport->connect(); + + $firstResponse = $transport->send([ + 'action' => 'anything', + ]); + + $secondResponse = $transport->send([ + 'action' => 'target', + 'payload' => 'value', + ]); + + $thirdResponse = $transport->send([ + 'action' => 'other', + ]); + + $this->assertSame(['result' => 'first'], $firstResponse); + $this->assertSame(['result' => 'value'], $secondResponse); + $this->assertSame(['result' => 'fallback'], $thirdResponse); + $this->assertSame(0, $transport->getPendingResponseCount()); + } + + public function testSendMatcherMustReturnBoolean(): void + { + $transport = new MockTransport(); + $transport->queueResponse( + ['result' => 'never-used'], + static fn (): string => 'yes', + ); + $transport->connect(); + + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('matcher must return a boolean'); + + $transport->send(['action' => 'anything']); + } + + public function testSendCallbackMustReturnArray(): void + { + $transport = new MockTransport(); + $transport->queueResponse( + static fn (): string => 'oops', + ); + $transport->connect(); + + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('response callback must return an array'); + + $transport->send(['action' => 'bad']); + } + + public function testSendThrowsScriptedThrowable(): void + { + $transport = new MockTransport(); + $transport->queueResponse(new \RuntimeException('boom')); + $transport->connect(); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('boom'); + + $transport->send(['action' => 'explode']); + } + + public function testSendThrowsWhenNoResponseMatches(): void + { + $transport = new MockTransport(); + $transport->queueResponse( + ['result' => 'ignored'], + static fn (array $message): bool => 'expected' === ($message['action'] ?? null), + ); + $transport->connect(); + + $this->expectException(\UnderflowException::class); + $this->expectExceptionMessage('[action=other]'); + + $transport->send(['action' => 'other']); + } + + public function testSendThrowsWhenNoResponseMatchesUsesJsonEncoding(): void + { + $transport = new MockTransport(); + $transport->connect(); + + $this->expectException(\UnderflowException::class); + $this->expectExceptionMessage('{"foo":"bar"}'); + + $transport->send(['foo' => 'bar']); + } + + public function testSendThrowsWhenNoResponseMatchesHandlesUnserializableMessage(): void + { + $transport = new MockTransport(); + $transport->connect(); + $resource = fopen('php://memory', 'r'); + + try { + $this->expectException(\UnderflowException::class); + $this->expectExceptionMessage('[unserializable-message]'); + + $transport->send(['resource' => $resource]); + } finally { + if (is_resource($resource)) { + fclose($resource); + } + } + } + + public function testSendAsyncRecordsPayloadAndInvokesHandlers(): void + { + $transport = new MockTransport(); + $transport->connect(); + $messages = []; + $transport->onSendAsync(static function (array $message) use (&$messages): void { + $messages[] = $message; + }); + $transport->onSendAsync(static function (array $message, MockTransport $self): void { + $self->queueResponse(['result' => $message['action'] ?? '']); + }); + + $transport->sendAsync(['action' => 'async.call']); + + $this->assertSame([['action' => 'async.call']], $transport->getAsyncMessages()); + $this->assertSame([['action' => 'async.call']], $messages); + $this->assertSame(1, $transport->getPendingResponseCount()); + } + + public function testProcessEventsExecutesCallbacks(): void + { + $transport = new MockTransport(); + $transport->queueProcessEvent(static fn (MockTransport $self): int => $self->getPendingResponseCount()); + $transport->queueProcessEvent(static fn (): string => 'done'); + $transport->connect(); + + $transport->processEvents(); + + $this->assertSame(0, $transport->getPendingEventCount()); + $this->assertSame([0, 'done'], $transport->getProcessedEventResults()); + } + + public function testResetHistoryClearsRecordedState(): void + { + $transport = new MockTransport(); + $transport->queueResponse(['result' => 'ok']); + $transport->queueProcessEvent(static fn (): string => 'x'); + $transport->connect(); + $transport->send(['action' => 'once']); + $transport->sendAsync(['action' => 'async']); + $transport->processEvents(); + + $transport->resetHistory(); + $transport->disconnect(); + + $this->assertSame([], $transport->getSentMessages()); + $this->assertSame([], $transport->getAsyncMessages()); + $this->assertSame([], $transport->getProcessedEventResults()); + $this->assertSame(0, $transport->getPendingResponseCount()); + $this->assertSame(0, $transport->getPendingEventCount()); + $this->assertFalse($transport->isConnected()); + } + + public function testStorePendingCallbackAndExecute(): void + { + $transport = new MockTransport(); + $transport->connect(); + + $executed = false; + $transport->storePendingCallback('req-1', static function (array $message) use (&$executed): void { + $executed = $message['trigger'] ?? false; + }); + + $callbacks = $transport->getStoredPendingCallbacks(); + + $this->assertArrayHasKey('req-1', $callbacks); + + $transport->executePendingCallback('req-1', ['trigger' => true]); + + $this->assertTrue($executed); + } + + public function testExecutePendingCallbackThrowsForUnknownRequest(): void + { + $transport = new MockTransport(); + $transport->connect(); + + $this->expectException(\OutOfBoundsException::class); + $this->expectExceptionMessage('No pending callback stored for request "missing"'); + + $transport->executePendingCallback('missing'); + } +} diff --git a/tests/Unit/Transport/TraceableTransportTest.php b/tests/Unit/Transport/TraceableTransportTest.php new file mode 100644 index 0000000..6660757 --- /dev/null +++ b/tests/Unit/Transport/TraceableTransportTest.php @@ -0,0 +1,289 @@ +connect(); + $transport->disconnect(); + + $this->assertFalse($inner->isConnected()); + $this->assertSame([ + ['method' => 'connect', 'exception' => null], + ['method' => 'disconnect', 'exception' => null], + ], $transport->getConnectionCalls()); + } + + public function testConnectionExceptionIsRecordedAndRethrown(): void + { + $inner = new InstrumentedTransport(); + $inner->connectException = new \RuntimeException('connect-failed'); + $transport = new TraceableTransport($inner); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('connect-failed'); + + try { + $transport->connect(); + } finally { + $log = $transport->getConnectionCalls(); + $this->assertCount(1, $log); + $this->assertSame('connect', $log[0]['method']); + $this->assertInstanceOf(\RuntimeException::class, $log[0]['exception']); + $this->assertSame('connect-failed', $log[0]['exception']->getMessage()); + } + } + + public function testDisconnectExceptionIsRecordedAndRethrown(): void + { + $inner = new InstrumentedTransport(); + $inner->disconnectException = new \RuntimeException('disconnect-failed'); + $transport = new TraceableTransport($inner); + + $transport->connect(); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('disconnect-failed'); + + try { + $transport->disconnect(); + } finally { + $log = $transport->getConnectionCalls(); + $this->assertCount(2, $log); + $this->assertSame('connect', $log[0]['method']); + $this->assertNull($log[0]['exception']); + $this->assertSame('disconnect', $log[1]['method']); + $this->assertInstanceOf(\RuntimeException::class, $log[1]['exception']); + } + } + + public function testSendRecordsSuccessfulResponse(): void + { + $inner = new InstrumentedTransport(); + $inner->onSend = static function (array $message): array { + return ['echo' => $message['action'] ?? null]; + }; + $transport = new TraceableTransport($inner); + + $result = $transport->send(['action' => 'do.it']); + + $this->assertSame(['echo' => 'do.it'], $result); + $record = $transport->getSendCalls(); + $this->assertCount(1, $record); + $this->assertSame(['action' => 'do.it'], $record[0]['message']); + $this->assertSame(['echo' => 'do.it'], $record[0]['response']); + $this->assertNull($record[0]['exception']); + } + + public function testSendRecordsException(): void + { + $inner = new InstrumentedTransport(); + $inner->onSend = static function (): array { + throw new \RuntimeException('send-failed'); + }; + $transport = new TraceableTransport($inner); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('send-failed'); + + try { + $transport->send(['action' => 'boom']); + } finally { + $record = $transport->getSendCalls(); + $this->assertCount(1, $record); + $this->assertSame(['action' => 'boom'], $record[0]['message']); + $this->assertNull($record[0]['response']); + $this->assertInstanceOf(\RuntimeException::class, $record[0]['exception']); + $this->assertSame('send-failed', $record[0]['exception']->getMessage()); + } + } + + public function testSendAsyncRecordsCall(): void + { + $inner = new InstrumentedTransport(); + $transport = new TraceableTransport($inner); + + $transport->sendAsync(['action' => 'notify']); + + $record = $transport->getAsyncCalls(); + $this->assertCount(1, $record); + $this->assertSame(['action' => 'notify'], $record[0]['message']); + $this->assertNull($record[0]['exception']); + } + + public function testSendAsyncRecordsException(): void + { + $inner = new InstrumentedTransport(); + $inner->onSendAsync = static function (): void { + throw new \RuntimeException('async-failed'); + }; + $transport = new TraceableTransport($inner); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('async-failed'); + + try { + $transport->sendAsync(['action' => 'notify']); + } finally { + $record = $transport->getAsyncCalls(); + $this->assertCount(1, $record); + $this->assertSame(['action' => 'notify'], $record[0]['message']); + $this->assertInstanceOf(\RuntimeException::class, $record[0]['exception']); + } + } + + public function testProcessEventsRecordsCall(): void + { + $inner = new InstrumentedTransport(); + $transport = new TraceableTransport($inner); + + $transport->processEvents(); + + $record = $transport->getProcessEventsCalls(); + $this->assertCount(1, $record); + $this->assertNull($record[0]['exception']); + } + + public function testProcessEventsRecordsException(): void + { + $inner = new InstrumentedTransport(); + $inner->onProcessEvents = static function (): void { + throw new \RuntimeException('event-failed'); + }; + $transport = new TraceableTransport($inner); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('event-failed'); + + try { + $transport->processEvents(); + } finally { + $record = $transport->getProcessEventsCalls(); + $this->assertCount(1, $record); + $this->assertInstanceOf(\RuntimeException::class, $record[0]['exception']); + } + } + + public function testResetClearsRecordedHistory(): void + { + $inner = new InstrumentedTransport(); + $transport = new TraceableTransport($inner); + + $transport->connect(); + $transport->send(['action' => 'ping']); + $transport->sendAsync(['action' => 'notify']); + $transport->processEvents(); + $transport->disconnect(); + + $transport->reset(); + + $this->assertSame([], $transport->getConnectionCalls()); + $this->assertSame([], $transport->getSendCalls()); + $this->assertSame([], $transport->getAsyncCalls()); + $this->assertSame([], $transport->getProcessEventsCalls()); + } + + public function testIsConnectedDelegatesToDecoratedTransport(): void + { + $inner = new InstrumentedTransport(); + $transport = new TraceableTransport($inner); + + $this->assertFalse($transport->isConnected()); + $transport->connect(); + $this->assertTrue($transport->isConnected()); + } +} + +/** + * @internal + */ +final class InstrumentedTransport implements TransportInterface +{ + public ?\Throwable $connectException = null; + public ?\Throwable $disconnectException = null; + /** @var callable(array): array|null */ + public $onSend; + /** @var callable(array): void|null */ + public $onSendAsync; + /** @var callable(): void|null */ + public $onProcessEvents; + + private bool $connected = false; + + public function connect(): void + { + if ($this->connectException instanceof \Throwable) { + throw $this->connectException; + } + + $this->connected = true; + } + + public function disconnect(): void + { + if ($this->disconnectException instanceof \Throwable) { + throw $this->disconnectException; + } + + $this->connected = false; + } + + /** + * @param array $message + * + * @return array + */ + public function send(array $message): array + { + if (is_callable($this->onSend)) { + return ($this->onSend)($message); + } + + return ['default' => true]; + } + + /** + * @param array $message + */ + public function sendAsync(array $message): void + { + if (is_callable($this->onSendAsync)) { + ($this->onSendAsync)($message); + } + } + + public function isConnected(): bool + { + return $this->connected; + } + + public function processEvents(): void + { + if (is_callable($this->onProcessEvents)) { + ($this->onProcessEvents)(); + } + } +} diff --git a/tests/Unit/WebSocket/WebSocketRouteTest.php b/tests/Unit/WebSocket/WebSocketRouteTest.php index 1c2b74f..7e0b52b 100644 --- a/tests/Unit/WebSocket/WebSocketRouteTest.php +++ b/tests/Unit/WebSocket/WebSocketRouteTest.php @@ -16,7 +16,7 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; -use Playwright\Transport\TransportInterface; +use Playwright\Transport\MockTransport; use Playwright\WebSocket\WebSocketRoute; use Playwright\WebSocket\WebSocketRouteInterface; @@ -25,33 +25,8 @@ final class WebSocketRouteTest extends TestCase { public function testUrlReturnsGivenUrl(): void { - $transport = new class implements TransportInterface { - public function connect(): void - { - } - - public function disconnect(): void - { - } - - public function send(array $message): array - { - return []; - } - - public function sendAsync(array $message): void - { - } - - public function isConnected(): bool - { - return true; - } - - public function processEvents(): void - { - } - }; + $transport = new MockTransport(); + $transport->connect(); $route = new WebSocketRoute($transport, 'route_1', 'wss://example/ws'); $this->assertSame('wss://example/ws', $route->url()); @@ -60,41 +35,11 @@ public function processEvents(): void public function testCloseSendsMessage(): void { $captured = []; - $transport = new class($captured) implements TransportInterface { - public array $captured; - - public function __construct(&$captured) - { - $this->captured = &$captured; - } - - public function connect(): void - { - } - - public function disconnect(): void - { - } - - public function send(array $message): array - { - return []; - } - - public function sendAsync(array $message): void - { - $this->captured[] = $message; - } - - public function isConnected(): bool - { - return true; - } - - public function processEvents(): void - { - } - }; + $transport = new MockTransport(); + $transport->connect(); + $transport->onSendAsync(function (array $message) use (&$captured): void { + $captured[] = $message; + }); $route = new WebSocketRoute($transport, 'route_1', 'wss://example/ws'); $route->close(['code' => 1000, 'reason' => 'Normal']); @@ -108,80 +53,22 @@ public function processEvents(): void public function testSendSendsMessage(): void { - $captured = []; - $transport = new class($captured) implements TransportInterface { - public array $captured; - - public function __construct(&$captured) - { - $this->captured = &$captured; - } - - public function connect(): void - { - } - - public function disconnect(): void - { - } - - public function send(array $message): array - { - return []; - } - - public function sendAsync(array $message): void - { - $this->captured[] = $message; - } - - public function isConnected(): bool - { - return true; - } - - public function processEvents(): void - { - } - }; + $transport = new MockTransport(); + $transport->connect(); $route = new WebSocketRoute($transport, 'route_1', 'wss://example/ws'); $route->send('ping'); - $this->assertSame('websocketRoute.send', $captured[0]['action'] ?? null); - $this->assertSame('route_1', $captured[0]['routeId'] ?? null); - $this->assertSame('ping', $captured[0]['message'] ?? null); + $messages = $transport->getAsyncMessages(); + $this->assertSame('websocketRoute.send', $messages[0]['action'] ?? null); + $this->assertSame('route_1', $messages[0]['routeId'] ?? null); + $this->assertSame('ping', $messages[0]['message'] ?? null); } public function testHandlersAreInvokedViaDispatchEvent(): void { - $transport = new class implements TransportInterface { - public function connect(): void - { - } - - public function disconnect(): void - { - } - - public function send(array $message): array - { - return []; - } - - public function sendAsync(array $message): void - { - } - - public function isConnected(): bool - { - return true; - } - - public function processEvents(): void - { - } - }; + $transport = new MockTransport(); + $transport->connect(); $route = new WebSocketRoute($transport, 'route_1', 'wss://example/ws'); @@ -202,33 +89,15 @@ public function processEvents(): void public function testConnectToServerReturnsNewInstanceWhenServerGivesId(): void { - $transport = new class implements TransportInterface { - public function connect(): void - { - } - - public function disconnect(): void - { - } - - public function send(array $message): array - { + $transport = new MockTransport(); + $transport->connect(); + $transport->queueResponse(function (array $message): array { + if (($message['action'] ?? null) === 'websocketRoute.connectToServer') { return ['serverRouteId' => 'route_server', 'url' => 'wss://server/ws']; } - public function sendAsync(array $message): void - { - } - - public function isConnected(): bool - { - return true; - } - - public function processEvents(): void - { - } - }; + return []; + }); $route = new WebSocketRoute($transport, 'route_1', 'wss://example/ws'); $serverRoute = $route->connectToServer(); diff --git a/tests/Unit/WebSocket/WebSocketTest.php b/tests/Unit/WebSocket/WebSocketTest.php index 19eb56e..7fd8eb9 100644 --- a/tests/Unit/WebSocket/WebSocketTest.php +++ b/tests/Unit/WebSocket/WebSocketTest.php @@ -16,7 +16,7 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; -use Playwright\Transport\TransportInterface; +use Playwright\Transport\MockTransport; use Playwright\WebSocket\WebSocket; #[CoversClass(WebSocket::class)] @@ -24,33 +24,8 @@ final class WebSocketTest extends TestCase { public function testUrlReturnsGivenUrl(): void { - $transport = new class implements TransportInterface { - public function connect(): void - { - } - - public function disconnect(): void - { - } - - public function send(array $message): array - { - return []; - } - - public function sendAsync(array $message): void - { - } - - public function isConnected(): bool - { - return true; - } - - public function processEvents(): void - { - } - }; + $transport = new MockTransport(); + $transport->connect(); $ws = new WebSocket($transport, 'ws_1', 'wss://example.test/socket'); @@ -59,33 +34,8 @@ public function processEvents(): void public function testIsClosedBecomesTrueAfterCloseEvent(): void { - $transport = new class implements TransportInterface { - public function connect(): void - { - } - - public function disconnect(): void - { - } - - public function send(array $message): array - { - return []; - } - - public function sendAsync(array $message): void - { - } - - public function isConnected(): bool - { - return true; - } - - public function processEvents(): void - { - } - }; + $transport = new MockTransport(); + $transport->connect(); $ws = new WebSocket($transport, 'ws_1', 'wss://example.test/socket'); $this->assertFalse($ws->isClosed()); @@ -98,55 +48,19 @@ public function processEvents(): void public function testWaitForEventResolvesWithPredicate(): void { - $cb = null; // set after ws is created - $transport = new class($cb) implements TransportInterface { - public $onProcess; - - public function __construct(&$onProcess) - { - $this->onProcess = &$onProcess; - } - - public function connect(): void - { - } - - public function disconnect(): void - { - } - - public function send(array $message): array - { - return []; - } - - public function sendAsync(array $message): void - { - } - - public function isConnected(): bool - { - return true; - } - - public function processEvents(): void - { - if (is_callable($this->onProcess)) { - ($this->onProcess)(); - } - } - }; + $transport = new MockTransport(); + $transport->connect(); $ws = new WebSocket($transport, 'ws_1', 'wss://example.test/socket'); $emitted = false; - $transport->onProcess = function () use ($ws, &$emitted): void { + $transport->queueProcessEvent(function () use ($ws, &$emitted): void { if ($emitted) { return; } $ws->dispatchEvent('framereceived', ['payload' => 'hello']); $emitted = true; - }; + }); $result = $ws->waitForEvent('framereceived', [ 'predicate' => fn ($e) => is_array($e) && (($e['payload'] ?? '') === 'hello'),