diff --git a/bin/lib/handlers.js b/bin/lib/handlers.js index 6210335..316adf3 100644 --- a/bin/lib/handlers.js +++ b/bin/lib/handlers.js @@ -768,14 +768,14 @@ class LocatorHandler extends BaseHandler { isDisabled: () => PromiseUtils.wrapValue(locator.isDisabled()), isEditable: () => PromiseUtils.wrapValue(locator.isEditable()), isVisible: () => PromiseUtils.wrapValue(locator.isVisible()), - isEnabled: () => PromiseUtils.wrapValue(locator.isEnabled()), + isEnabled: () => PromiseUtils.wrapValue(locator.isEnabled(command.options)), isChecked: () => PromiseUtils.wrapValue(locator.isChecked()), - textContent: () => PromiseUtils.wrapValue(locator.textContent()), + textContent: () => PromiseUtils.wrapValue(locator.textContent(command.options)), innerText: () => PromiseUtils.wrapValue(locator.innerText()), innerHTML: () => PromiseUtils.wrapValue(locator.innerHTML()), inputValue: () => PromiseUtils.wrapValue(locator.inputValue()), count: () => PromiseUtils.wrapValue(locator.count()), - getAttribute: () => PromiseUtils.wrapValue(locator.getAttribute(command.name)), + getAttribute: () => PromiseUtils.wrapValue(locator.getAttribute(command.name, command.options)), selectOption: () => PromiseUtils.wrapValues(locator.selectOption(command.values, command.options)), screenshot: () => PromiseUtils.wrapBinary(locator.screenshot(command.options)), evaluate: () => this.evaluateLocator(locator, command), diff --git a/src/Locator/Locator.php b/src/Locator/Locator.php index b9acc17..7033cc0 100644 --- a/src/Locator/Locator.php +++ b/src/Locator/Locator.php @@ -110,12 +110,12 @@ public function getOptions(): array /** * @param array|ClickOptions $options + * @param int|null $waitForActionableTimeout Native action timeout override in milliseconds */ - public function click(array|ClickOptions $options = [], int $waitForActionableTimeout = 30000): void + public function click(array|ClickOptions $options = [], ?int $waitForActionableTimeout = null): void { - $options = ClickOptions::from($options); - $this->waitForActionable(['timeout' => $waitForActionableTimeout]); - $this->sendCommand('locator.click', ['options' => $options->toArray()]); + $options = $this->withActionTimeout(ClickOptions::from($options)->toArray(), $waitForActionableTimeout); + $this->sendCommand('locator.click', ['options' => $options]); } /** @@ -496,12 +496,12 @@ public function setInputFiles(string|array $files, array|SetInputFilesOptions $o /** * @param array|FillOptions $options + * @param int|null $waitForActionableTimeout Native action timeout override in milliseconds */ - public function fill(string $value, array|FillOptions $options = [], int $waitForActionableTimeout = 30000): void + public function fill(string $value, array|FillOptions $options = [], ?int $waitForActionableTimeout = null): void { - $options = FillOptions::from($options); - $this->waitForActionable(['timeout' => $waitForActionableTimeout]); - $this->sendCommand('locator.fill', ['value' => $value, 'options' => $options->toArray()]); + $options = $this->withActionTimeout(FillOptions::from($options)->toArray(), $waitForActionableTimeout); + $this->sendCommand('locator.fill', ['value' => $value, 'options' => $options]); } /** @@ -570,17 +570,17 @@ public function hover(array|HoverOptions $options = []): void * - timeout: int - Maximum time in milliseconds * * @param array|DragToOptions $options + * @param int|null $waitForActionableTimeout Native action timeout override in milliseconds */ - public function dragTo(LocatorInterface $target, array|DragToOptions $options = [], int $waitForActionableTimeout = 30000): void + public function dragTo(LocatorInterface $target, array|DragToOptions $options = [], ?int $waitForActionableTimeout = null): void { - $options = DragToOptions::from($options); - $this->waitForActionable(['timeout' => $waitForActionableTimeout]); + $options = $this->withActionTimeout(DragToOptions::from($options)->toArray(), $waitForActionableTimeout); $targetSelector = $target->getSelector(); $this->sendCommand('locator.dragAndDrop', [ 'target' => $targetSelector, - 'options' => $options->toArray(), + 'options' => $options, ]); $this->transport->processEvents(); @@ -823,56 +823,66 @@ private static function startsWithReturn(string $s): bool /** * @param array $options */ - private function waitForActionable(array $options = []): void + private function extractTimeout(array $options, int $default = 30000): int { - $timeout = $this->extractTimeout($options); - $this->waitForCondition( - fn () => $this->isVisible() && $this->isEnabled(), - $timeout, - 'Element not actionable' - ); + $timeout = $options['timeout'] ?? $default; + + return is_int($timeout) && $timeout >= 0 ? $timeout : $default; } /** * @param array $options + * + * @return array */ - private function extractTimeout(array $options, int $default = 30000): int + private function withActionTimeout(array $options, ?int $timeout): array { - $timeout = $options['timeout'] ?? $default; + if (null !== $timeout) { + $options['timeout'] = (float) $timeout; + } - return is_int($timeout) && $timeout > 0 ? $timeout : $default; + return $options; } + /** + * @param callable(int): bool $condition + */ private function waitForCondition(callable $condition, int $timeoutMs, string $message): void { - $start = microtime(true); - $timeoutSeconds = $timeoutMs / 1000; + $deadline = $timeoutMs > 0 ? hrtime(true) + ($timeoutMs * 1_000_000) : null; - while ((microtime(true) - $start) < $timeoutSeconds) { - try { - if ($condition()) { - return; - } - } catch (PlaywrightException $e) { + while (null === $deadline || hrtime(true) < $deadline) { + $remainingTimeoutMs = null === $deadline + ? 0 + : max(1, (int) ceil(($deadline - hrtime(true)) / 1_000_000)); + if ($condition($remainingTimeoutMs)) { + return; } - usleep(100000); + $remainingMicroseconds = null === $deadline + ? 100000 + : (int) ceil(($deadline - hrtime(true)) / 1000); + if ($remainingMicroseconds > 0) { + usleep(min(100000, $remainingMicroseconds)); + } } throw new TimeoutException(sprintf('%s (timeout: %dms)', $message, $timeoutMs)); } + private function isEnabledWithTimeout(int $timeoutMs): bool + { + $response = $this->sendCommand('locator.isEnabled', ['options' => ['timeout' => (float) $timeoutMs]]); + + return true === $response['value']; + } + /** * @param array $options */ public function waitForAttached(array $options = []): void { - $timeout = $this->extractTimeout($options); - $this->waitForCondition( - fn () => $this->isAttached(), - $timeout, - 'Element not attached' - ); + $this->waitFor([...$options, 'state' => 'attached']); } /** @@ -880,12 +890,7 @@ public function waitForAttached(array $options = []): void */ public function waitForDetached(array $options = []): void { - $timeout = $this->extractTimeout($options); - $this->waitForCondition( - fn () => !$this->isAttached(), - $timeout, - 'Element still attached' - ); + $this->waitFor([...$options, 'state' => 'detached']); } /** @@ -893,12 +898,7 @@ public function waitForDetached(array $options = []): void */ public function waitForVisible(array $options = []): void { - $timeout = $this->extractTimeout($options); - $this->waitForCondition( - fn () => $this->isVisible(), - $timeout, - 'Element not visible' - ); + $this->waitFor([...$options, 'state' => 'visible']); } /** @@ -906,12 +906,7 @@ public function waitForVisible(array $options = []): void */ public function waitForHidden(array $options = []): void { - $timeout = $this->extractTimeout($options); - $this->waitForCondition( - fn () => $this->isHidden(), - $timeout, - 'Element still visible' - ); + $this->waitFor([...$options, 'state' => 'hidden']); } /** @@ -921,7 +916,7 @@ public function waitForEnabled(array $options = []): void { $timeout = $this->extractTimeout($options); $this->waitForCondition( - fn () => $this->isEnabled(), + fn (int $remainingTimeoutMs) => $this->isEnabledWithTimeout($remainingTimeoutMs), $timeout, 'Element not enabled' ); @@ -934,7 +929,7 @@ public function waitForText(string $text, array $options = []): void { $timeout = $this->extractTimeout($options); $this->waitForCondition( - fn () => str_contains($this->textContent() ?? '', $text), + fn (int $remainingTimeoutMs) => str_contains($this->textContent(['timeout' => $remainingTimeoutMs]) ?? '', $text), $timeout, sprintf('Element does not contain text: %s', $text) ); diff --git a/src/Transport/JsonRpc/JsonRpcTransport.php b/src/Transport/JsonRpc/JsonRpcTransport.php index 7cd2168..9be50f1 100644 --- a/src/Transport/JsonRpc/JsonRpcTransport.php +++ b/src/Transport/JsonRpc/JsonRpcTransport.php @@ -35,6 +35,81 @@ final class JsonRpcTransport implements TransportInterface */ private const OPERATION_TIMEOUT_GRACE_MS = 5000; + /** + * Commands whose native Playwright timeout owns the deadline when omitted. + * + * @var list + */ + private const NATIVE_TIMEOUT_ACTIONS = [ + 'api.fetch', + 'connect', + 'connectOverCDP', + 'context.waitForEvent', + 'context.waitForPopup', + 'fileChooser.setFiles', + 'frame.dragAndDrop', + 'frame.goto', + 'frame.setContent', + 'frame.waitForFunction', + 'frame.waitForLoadState', + 'frame.waitForNavigation', + 'frame.waitForURL', + 'launch', + 'launchServer', + 'locator.ariaSnapshot', + 'locator.boundingBox', + 'locator.blur', + 'locator.check', + 'locator.clear', + 'locator.click', + 'locator.dblclick', + 'locator.dispatchEvent', + 'locator.dragAndDrop', + 'locator.drop', + 'locator.fill', + 'locator.focus', + 'locator.getAttribute', + 'locator.hover', + 'locator.innerHTML', + 'locator.innerText', + 'locator.inputValue', + 'locator.isChecked', + 'locator.isDisabled', + 'locator.isEditable', + 'locator.isEnabled', + 'locator.press', + 'locator.pressSequentially', + 'locator.screenshot', + 'locator.scrollIntoViewIfNeeded', + 'locator.selectOption', + 'locator.selectText', + 'locator.setChecked', + 'locator.setInputFiles', + 'locator.tap', + 'locator.textContent', + 'locator.type', + 'locator.uncheck', + 'locator.waitFor', + 'locator.waitForFunction', + 'page.ariaSnapshot', + 'page.dragAndDrop', + 'page.goBack', + 'page.goForward', + 'page.goto', + 'page.reload', + 'page.screenshot', + 'page.setContent', + 'page.waitForFunction', + 'page.waitForLoadState', + 'page.waitForPopup', + 'page.waitForRequest', + 'page.waitForResponse', + 'page.waitForSelector', + 'page.waitForURL', + 'route.fetch', + 'worker.waitForEvent', + ]; + private ?Process $process = null; private ?JsonRpcClient $client = null; private bool $connected = false; @@ -166,19 +241,7 @@ public function send(array $message): array $this->ensureConnected(); try { - $timeout = $this->config['timeout'] ?? null; - $timeoutMs = null; - if (null !== $timeout) { - if (!is_numeric($timeout)) { - throw new NetworkException('Invalid timeout: must be numeric'); - } - $timeoutMs = (int) ($timeout * 1000); - } - - $operationTimeoutMs = $this->extractOperationTimeoutMs($message); - if (null !== $operationTimeoutMs) { - $timeoutMs = max($timeoutMs ?? 30000, $operationTimeoutMs + self::OPERATION_TIMEOUT_GRACE_MS); - } + $timeoutMs = $this->resolveRequestTimeoutMs($message); if (null === $this->client) { throw new NetworkException('JSON-RPC client not available'); @@ -204,10 +267,39 @@ public function send(array $message): array */ private function extractOperationTimeoutMs(array $message): ?int { + $action = $message['action'] ?? null; + if (!is_string($action) || !in_array($action, self::NATIVE_TIMEOUT_ACTIONS, true)) { + return null; + } + $options = $message['options'] ?? null; - $timeout = \is_array($options) && isset($options['timeout']) ? $options['timeout'] : ($message['timeout'] ?? null); + if (\is_array($options)) { + $timeout = $options['timeout'] ?? 0; + } else { + $timeout = $message['timeout'] ?? null; + } + + return is_numeric($timeout) && $timeout >= 0 ? (int) $timeout : null; + } + + /** + * @param array $message + */ + private function resolveRequestTimeoutMs(array $message): int + { + $configuredTimeout = $this->config['timeout'] ?? null; + $configuredTimeoutMs = is_int($configuredTimeout) || is_float($configuredTimeout) + ? (int) ($configuredTimeout * 1000) + : 0; + $operationTimeoutMs = $this->extractOperationTimeoutMs($message); + if (null === $operationTimeoutMs) { + return $configuredTimeoutMs; + } + if (0 === $operationTimeoutMs) { + return 0; + } - return is_numeric($timeout) && $timeout > 0 ? (int) $timeout : null; + return max($configuredTimeoutMs, $operationTimeoutMs + self::OPERATION_TIMEOUT_GRACE_MS); } /** diff --git a/tests/Functional/Transport/RequestTimeoutTest.php b/tests/Functional/Transport/RequestTimeoutTest.php index 2fa1ec2..2b49385 100644 --- a/tests/Functional/Transport/RequestTimeoutTest.php +++ b/tests/Functional/Transport/RequestTimeoutTest.php @@ -16,6 +16,7 @@ use PHPUnit\Framework\Attributes\CoversClass; use Playwright\Configuration\PlaywrightConfig; +use Playwright\Exception\PlaywrightException; use Playwright\Exception\TimeoutException; use Playwright\PlaywrightFactory; use Playwright\Tests\Functional\FunctionalTestCase; @@ -25,6 +26,62 @@ #[CoversClass(JsonRpcTransport::class)] final class RequestTimeoutTest extends FunctionalTestCase { + public function testCommandWithoutANativeTimeoutUsesTheTransportDeadline(): void + { + $node = (new ExecutableFinder())->find('node'); + if (null === $node) { + $this->markTestSkipped('Node.js executable not found.'); + } + + $config = new PlaywrightConfig(nodePath: $node, timeoutMs: 2000); + $playwright = PlaywrightFactory::create($config); + $browser = $playwright->chromium()->launch(); + + try { + $page = $browser->newPage(); + $start = microtime(true); + + try { + $page->evaluate('new Promise(resolve => setTimeout(resolve, 3000))'); + $this->fail('Expected a TimeoutException'); + } catch (TimeoutException) { + $this->assertLessThan(2.8, microtime(true) - $start); + } + } finally { + $browser->close(); + $playwright->close(); + } + } + + public function testDefaultOperationTimeoutIsNotCutByTheTransportDeadline(): void + { + $node = (new ExecutableFinder())->find('node'); + if (null === $node) { + $this->markTestSkipped('Node.js executable not found.'); + } + + $config = new PlaywrightConfig(nodePath: $node, timeoutMs: 5000); + $playwright = PlaywrightFactory::create($config); + $browser = $playwright->chromium()->launch(); + + try { + $context = $browser->newContext(); + $page = $context->newPage(); + $page->setContent('

empty

'); + $page->setDefaultTimeout(7000); + + try { + $page->locator('#does-not-exist')->click(); + $this->fail('Expected a TimeoutException'); + } catch (PlaywrightException $e) { + $this->assertStringContainsString('Timeout 7000ms exceeded', $e->getMessage()); + $this->assertStringContainsString('waiting for locator', $e->getMessage()); + } + } finally { + $browser->close(); + } + } + public function testAnOperationTimeoutIsNotCutByTheTransportDeadline(): void { $node = (new ExecutableFinder())->find('node'); diff --git a/tests/Functional/Waiting/AutoWaitingTest.php b/tests/Functional/Waiting/AutoWaitingTest.php index 10284a8..47b7549 100644 --- a/tests/Functional/Waiting/AutoWaitingTest.php +++ b/tests/Functional/Waiting/AutoWaitingTest.php @@ -73,6 +73,39 @@ public function testWaitsForElementToBeEnabled(): void $this->assertTrue($button->isEnabled()); } + public function testCustomWaitsUseTheirOwnTimeoutInsteadOfThePageDefault(): void + { + $this->page->setContent('

Loading

'); + $this->page->evaluate(<<<'JS' +setTimeout(() => document.querySelector('button').disabled = false, 350); +setTimeout(() => document.querySelector('p').textContent = 'Ready', 700); +JS); + $this->page->setDefaultTimeout(100); + + $this->page->locator('button')->waitForEnabled(['timeout' => 1000]); + $this->page->locator('p')->waitForText('Ready', ['timeout' => 1000]); + + $this->assertTrue($this->page->locator('button')->isEnabled()); + $this->assertSame('Ready', $this->page->locator('p')->textContent()); + } + + public function testLocatorQueryUsesItsExplicitTimeoutInsteadOfThePageDefault(): void + { + $this->page->setContent('
'); + $this->page->evaluate(<<<'JS' +setTimeout(() => { + const element = document.createElement('p'); + element.dataset.state = 'ready'; + document.querySelector('main').appendChild(element); +}, 350); +JS); + $this->page->setDefaultTimeout(100); + + $state = $this->page->locator('p')->getAttribute('data-state', ['timeout' => 1000]); + + $this->assertSame('ready', $state); + } + public function testWaitsForElementToBeVisible(): void { $this->goto('/waiting.html'); @@ -99,15 +132,20 @@ public function testWaitsForAjaxContentToLoad(): void $this->assertSame('Data from server', $data); } - public function testClickAutomaticallyWaitsForElement(): void + public function testClickAutomaticallyWaitsForElementToBecomeActionable(): void { $this->goto('/waiting.html'); - $this->page->click('#show-after-delay'); + $this->page->evaluate(<<<'JS' +const button = document.querySelector('#enable-button-later'); +button.addEventListener('click', () => document.body.dataset.enabledButtonClicked = 'yes'); +JS); - $this->page->waitForSelector('#delayed-element.visible'); + $this->page->click('#trigger-enable'); + $this->page->locator('#enable-button-later')->click(); - $this->assertTrue($this->page->locator('#delayed-element')->isVisible()); + $clicked = $this->page->evaluate('document.body.dataset.enabledButtonClicked'); + $this->assertSame('yes', $clicked); } public function testFillAutomaticallyWaitsForElement(): void @@ -123,8 +161,6 @@ public function testFillAutomaticallyWaitsForElement(): void }, 500); JS); - $this->page->waitForSelector('#delayed-input'); - $this->page->locator('#delayed-input')->fill('test value'); $value = $this->page->locator('#delayed-input')->inputValue(); diff --git a/tests/Unit/Locator/LocatorTest.php b/tests/Unit/Locator/LocatorTest.php index 7800a32..038626e 100644 --- a/tests/Unit/Locator/LocatorTest.php +++ b/tests/Unit/Locator/LocatorTest.php @@ -577,32 +577,18 @@ public function testDragToBasic(): void $targetLocator = new Locator($this->transport, 'page1', '.target'); $this->transport - ->expects($this->exactly(3)) + ->expects($this->once()) ->method('send') - ->willReturnCallback(function ($payload) { - // First call: isVisible check - if ('locator.isVisible' === $payload['action']) { - return ['value' => true]; - } - // Second call: isEnabled check - if ('locator.isEnabled' === $payload['action']) { - return ['value' => true]; - } - // Third call: actual dragAndDrop - if ('locator.dragAndDrop' === $payload['action'] - && '.target' === $payload['target'] - && [] === $payload['options']) { - return ['value' => true]; - } - - return []; - }); + ->with($this->callback(static fn (array $payload): bool => 'locator.dragAndDrop' === $payload['action'] + && '.target' === $payload['target'] + && ['timeout' => 1.0] === $payload['options'])) + ->willReturn(['value' => true]); $this->transport ->expects($this->once()) ->method('processEvents'); - $this->locator->dragTo($targetLocator); + $this->locator->dragTo($targetLocator, waitForActionableTimeout: 1); } public function testDragToWithOptions(): void @@ -612,30 +598,16 @@ public function testDragToWithOptions(): void 'sourcePosition' => ['x' => 10, 'y' => 15], 'targetPosition' => ['x' => 20, 'y' => 25], 'force' => true, - 'timeout' => 5000, + 'timeout' => 5000.0, ]; $this->transport - ->expects($this->exactly(3)) + ->expects($this->once()) ->method('send') - ->willReturnCallback(function ($payload) use ($options) { - // First call: isVisible check - if ('locator.isVisible' === $payload['action']) { - return ['value' => true]; - } - // Second call: isEnabled check - if ('locator.isEnabled' === $payload['action']) { - return ['value' => true]; - } - // Third call: actual dragAndDrop - if ('locator.dragAndDrop' === $payload['action'] - && '.target' === $payload['target'] - && $options === $payload['options']) { - return ['value' => true]; - } - - return []; - }); + ->with($this->callback(static fn (array $payload): bool => 'locator.dragAndDrop' === $payload['action'] + && '.target' === $payload['target'] + && $options === $payload['options'])) + ->willReturn(['value' => true]); $this->transport ->expects($this->once()) @@ -649,25 +621,11 @@ public function testDragToWithComplexSelectors(): void $targetLocator = new Locator($this->transport, 'page1', '#dropzone .drop-target[data-accept="files"]'); $this->transport - ->expects($this->exactly(3)) + ->expects($this->once()) ->method('send') - ->willReturnCallback(function ($payload) { - // First call: isVisible check - if ('locator.isVisible' === $payload['action']) { - return ['value' => true]; - } - // Second call: isEnabled check - if ('locator.isEnabled' === $payload['action']) { - return ['value' => true]; - } - // Third call: actual dragAndDrop - if ('locator.dragAndDrop' === $payload['action'] - && '#dropzone .drop-target[data-accept="files"]' === $payload['target']) { - return ['value' => true]; - } - - return []; - }); + ->with($this->callback(static fn (array $payload): bool => 'locator.dragAndDrop' === $payload['action'] + && '#dropzone .drop-target[data-accept="files"]' === $payload['target'])) + ->willReturn(['value' => true]); $this->transport ->expects($this->once()) @@ -742,39 +700,44 @@ public function testGetByLabel(): void $this->assertSame('Locator(selector=".element >> label:text-is("Email") >> nth=0")', (string) $result); } - public function testClickWaitsForActionable(): void + public function testClickSendsNativeCommandWithoutActionabilityChecks(): void { - $callCount = 0; $this->transport - ->expects($this->exactly(3)) + ->expects($this->once()) ->method('send') - ->willReturnCallback(function ($payload) use (&$callCount) { - ++$callCount; - - if (1 === $callCount) { - $this->assertEquals('locator.isVisible', $payload['action']); - - return ['value' => true]; - } + ->with($this->callback(static fn (array $payload): bool => 'locator.click' === $payload['action'] + && ['timeout' => 1.0] === $payload['options'])) + ->willReturn([]); - if (2 === $callCount) { - $this->assertEquals('locator.isEnabled', $payload['action']); + $locator = new Locator($this->transport, 'page1', '.button'); - return ['value' => true]; - } + $locator->click(waitForActionableTimeout: 1); + } - if (3 === $callCount) { - $this->assertEquals('locator.click', $payload['action']); + public function testClickPropagatesJsonRpcTimeout(): void + { + $this->transport + ->expects($this->once()) + ->method('send') + ->with($this->callback(static fn (array $payload): bool => 'locator.click' === $payload['action'])) + ->willThrowException(new TimeoutException('JSON-RPC request locator.click timed out')); - return []; - } + $this->expectException(TimeoutException::class); + $this->expectExceptionMessage('JSON-RPC request locator.click timed out'); - return []; - }); + $this->locator->click(); + } - $locator = new Locator($this->transport, 'page1', '.button'); + public function testActionableTimeoutOverridesNativeOptionTimeout(): void + { + $this->transport + ->expects($this->once()) + ->method('send') + ->with($this->callback(static fn (array $payload): bool => 'locator.click' === $payload['action'] + && ['timeout' => 1000.0] === $payload['options'])) + ->willReturn([]); - $locator->click(); + $this->locator->click(['timeout' => 5000], 1000); } public function testWaitForVisibleSucceeds(): void @@ -782,30 +745,28 @@ public function testWaitForVisibleSucceeds(): void $this->transport ->expects($this->once()) ->method('send') - ->with($this->callback(function ($payload) { - return 'locator.isVisible' === $payload['action']; - })) - ->willReturn(['value' => true]); + ->with($this->callback(static fn (array $payload): bool => 'locator.waitFor' === $payload['action'] + && ['state' => 'visible'] === $payload['options'])) + ->willReturn([]); $locator = new Locator($this->transport, 'page1', '.element'); $locator->waitForVisible(); } - public function testWaitForVisibleTimeout(): void + public function testWaitForVisibleUsesNativeTimeout(): void { $this->transport - ->expects($this->atLeastOnce()) + ->expects($this->once()) ->method('send') - ->with($this->callback(function ($payload) { - return 'locator.isVisible' === $payload['action']; - })) - ->willReturn(['value' => false]); + ->with($this->callback(static fn (array $payload): bool => 'locator.waitFor' === $payload['action'] + && ['state' => 'visible', 'timeout' => 1000.0] === $payload['options'])) + ->willThrowException(new TimeoutException('Timeout 1000ms exceeded')); $locator = new Locator($this->transport, 'page1', '.element'); $this->expectException(TimeoutException::class); - $this->expectExceptionMessage('Element not visible (timeout: 1000ms)'); + $this->expectExceptionMessage('Timeout 1000ms exceeded'); $locator->waitForVisible(['timeout' => 1000]); } @@ -820,6 +781,9 @@ public function testWaitForTextContains(): void ++$callCount; if ('locator.textContent' === $payload['action']) { + $this->assertGreaterThan(0, $payload['options']['timeout']); + $this->assertLessThanOrEqual(30000, $payload['options']['timeout']); + return ['value' => 1 === $callCount ? 'Loading...' : 'Success: Data loaded']; } @@ -833,56 +797,31 @@ public function testWaitForTextContains(): void public function testWaitForHidden(): void { - $callCount = 0; $this->transport - ->expects($this->exactly(2)) + ->expects($this->once()) ->method('send') - ->willReturnCallback(function ($payload) use (&$callCount) { - ++$callCount; - - if ('locator.isHidden' === $payload['action']) { - return ['value' => 2 === $callCount]; - } - - return []; - }); + ->with($this->callback(static fn (array $payload): bool => 'locator.waitFor' === $payload['action'] + && ['state' => 'hidden'] === $payload['options'])) + ->willReturn([]); $locator = new Locator($this->transport, 'page1', '.modal'); $locator->waitForHidden(); } - public function testFillWaitsForActionable(): void + public function testFillSendsNativeCommandWithoutActionabilityChecks(): void { - $callCount = 0; $this->transport - ->expects($this->exactly(3)) + ->expects($this->once()) ->method('send') - ->willReturnCallback(function ($payload) use (&$callCount) { - ++$callCount; - - if ($callCount <= 2) { - if ('locator.isVisible' === $payload['action']) { - return ['value' => true]; - } - if ('locator.isEnabled' === $payload['action']) { - return ['value' => true]; - } - } - - if (3 === $callCount) { - $this->assertEquals('locator.fill', $payload['action']); - $this->assertEquals('test value', $payload['value']); - - return []; - } - - return []; - }); + ->with($this->callback(static fn (array $payload): bool => 'locator.fill' === $payload['action'] + && 'test value' === $payload['value'] + && ['timeout' => 1.0] === $payload['options'])) + ->willReturn([]); $locator = new Locator($this->transport, 'page1', 'input[type="text"]'); - $locator->fill('test value'); + $locator->fill('test value', waitForActionableTimeout: 1); } public function testWaitForAttached(): void @@ -890,10 +829,9 @@ public function testWaitForAttached(): void $this->transport ->expects($this->once()) ->method('send') - ->with($this->callback(function ($payload) { - return 'locator.isAttached' === $payload['action']; - })) - ->willReturn(['value' => true]); + ->with($this->callback(static fn (array $payload): bool => 'locator.waitFor' === $payload['action'] + && ['state' => 'attached'] === $payload['options'])) + ->willReturn([]); $locator = new Locator($this->transport, 'page1', '.dynamic-element'); @@ -905,16 +843,43 @@ public function testWaitForDetached(): void $this->transport ->expects($this->once()) ->method('send') - ->with($this->callback(function ($payload) { - return 'locator.isAttached' === $payload['action']; - })) - ->willReturn(['value' => false]); + ->with($this->callback(static fn (array $payload): bool => 'locator.waitFor' === $payload['action'] + && ['state' => 'detached'] === $payload['options'])) + ->willReturn([]); $locator = new Locator($this->transport, 'page1', '.removed-element'); $locator->waitForDetached(); } + public function testWaitForConditionPropagatesTransportTimeout(): void + { + $this->transport + ->expects($this->once()) + ->method('send') + ->with($this->callback(static fn (array $payload): bool => 'locator.isEnabled' === $payload['action'] + && $payload['options']['timeout'] > 0 + && $payload['options']['timeout'] <= 1000)) + ->willThrowException(new TimeoutException('JSON-RPC request locator.isEnabled timed out')); + + $this->expectException(TimeoutException::class); + $this->expectExceptionMessage('JSON-RPC request locator.isEnabled timed out'); + + $this->locator->waitForEnabled(['timeout' => 1000]); + } + + public function testUnlimitedWaitPassesZeroToTheNativeQuery(): void + { + $this->transport + ->expects($this->once()) + ->method('send') + ->with($this->callback(static fn (array $payload): bool => 'locator.isEnabled' === $payload['action'] + && ['timeout' => 0.0] === $payload['options'])) + ->willReturn(['value' => true]); + + $this->locator->waitForEnabled(['timeout' => 0]); + } + public function testFilterWithHasText(): void { $this->useItemsLocator(); diff --git a/tests/Unit/Page/PageTest.php b/tests/Unit/Page/PageTest.php index 4747e77..d97cbfe 100644 --- a/tests/Unit/Page/PageTest.php +++ b/tests/Unit/Page/PageTest.php @@ -301,22 +301,15 @@ public function testClickSendsCommand(): void $selector = 'button'; $options = ['force' => true]; - $this->transport->expects($this->exactly(3)) + $this->transport->expects($this->once()) ->method('send') - ->willReturnCallback(function (array $payload) { - if ('locator.isVisible' === $payload['action']) { - return ['value' => true]; - } - if ('locator.isEnabled' === $payload['action']) { - return ['value' => true]; - } - if ('locator.click' === $payload['action']) { - $this->assertSame(['force' => true], $payload['options']); - - return []; - } - $this->fail('Unexpected action: '.$payload['action']); - }); + ->with([ + 'options' => ['force' => true], + 'action' => 'locator.click', + 'pageId' => 'page-id', + 'selector' => $selector, + ]) + ->willReturn([]); $this->page->click($selector, $options); } diff --git a/tests/Unit/Transport/JsonRpc/JsonRpcTransportTest.php b/tests/Unit/Transport/JsonRpc/JsonRpcTransportTest.php index 944d53e..4ec9c24 100644 --- a/tests/Unit/Transport/JsonRpc/JsonRpcTransportTest.php +++ b/tests/Unit/Transport/JsonRpc/JsonRpcTransportTest.php @@ -248,7 +248,7 @@ private function injectClient(JsonRpcTransport $transport, JsonRpcClient $client (new \ReflectionProperty($transport, 'client'))->setValue($transport, $client); } - public function testSendPassesTheConfiguredTimeoutPerRequest(): void + public function testSendWithoutANativeOperationTimeoutUsesTheConfiguredDeadline(): void { $transport = $this->createConnectedTransport(['command' => ['node', 'server.js'], 'timeout' => 45]); @@ -259,13 +259,82 @@ public function testSendPassesTheConfiguredTimeoutPerRequest(): void ->willReturn([]); $this->injectClient($transport, $client); - $transport->send(['action' => 'launch']); + $transport->send(['action' => 'page.evaluate', 'expression' => 'new Promise(() => {})']); } - public function testSendExtendsTheDeadlineForAnOperationTimeout(): void + public function testSendWithNonTimeoutOptionsUsesTheConfiguredDeadline(): void + { + $transport = $this->createConnectedTransport(['command' => ['node', 'server.js'], 'timeout' => 45]); + + $client = $this->createMock(JsonRpcClient::class); + $client->expects($this->once()) + ->method('sendRaw') + ->with($this->anything(), 45000.0) + ->willReturn([]); + $this->injectClient($transport, $client); + + $transport->send([ + 'action' => 'page.addScriptTag', + 'options' => ['url' => 'https://example.com/script.js'], + ]); + } + + public function testSendLetsANativeDefaultTimeoutOwnTheDeadline(): void + { + $transport = $this->createConnectedTransport(['command' => ['node', 'server.js'], 'timeout' => 30]); + + $client = $this->createMock(JsonRpcClient::class); + $client->expects($this->once()) + ->method('sendRaw') + ->with($this->anything(), 0.0) + ->willReturn([]); + $this->injectClient($transport, $client); + + $transport->send([ + 'action' => 'locator.click', + 'options' => [], + ]); + } + + public function testSendDoesNotTreatConfigurationCommandsAsTimedOperations(): void { $transport = $this->createConnectedTransport(['command' => ['node', 'server.js'], 'timeout' => 30]); + $client = $this->createMock(JsonRpcClient::class); + $client->expects($this->once()) + ->method('sendRaw') + ->with($this->anything(), 30000.0) + ->willReturn([]); + $this->injectClient($transport, $client); + + $transport->send([ + 'action' => 'page.setDefaultTimeout', + 'timeout' => 60000, + ]); + } + + public function testSendDisablesTheTransportDeadlineForAnUnlimitedOperation(): void + { + $transport = $this->createConnectedTransport(['command' => ['node', 'server.js'], 'timeout' => 30]); + + $client = $this->createMock(JsonRpcClient::class); + $client->expects($this->once()) + ->method('sendRaw') + ->with($this->anything(), 0.0) + ->willReturn([]); + $this->injectClient($transport, $client); + + $transport->send([ + 'action' => 'locator.click', + 'pageId' => 'page_1', + 'options' => ['timeout' => 0], + ]); + } + + public function testSendExtendsTheDeadlineForAnOperationTimeout(): void + { + $transport = $this->createConnectedTransport(['command' => ['node', 'server.js']]); + $client = $this->createMock(JsonRpcClient::class); $client->expects($this->once()) ->method('sendRaw')