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
6 changes: 3 additions & 3 deletions bin/lib/handlers.js
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
109 changes: 52 additions & 57 deletions src/Locator/Locator.php
Original file line number Diff line number Diff line change
Expand Up @@ -110,12 +110,12 @@ public function getOptions(): array

/**
* @param array<string, mixed>|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]);
}

/**
Expand Down Expand Up @@ -496,12 +496,12 @@ public function setInputFiles(string|array $files, array|SetInputFilesOptions $o

/**
* @param array<string, mixed>|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]);
}

/**
Expand Down Expand Up @@ -570,17 +570,17 @@ public function hover(array|HoverOptions $options = []): void
* - timeout: int - Maximum time in milliseconds
*
* @param array<string, mixed>|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();
Expand Down Expand Up @@ -823,95 +823,90 @@ private static function startsWithReturn(string $s): bool
/**
* @param array<string, mixed> $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<string, mixed> $options
*
* @return array<string, mixed>
*/
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<string, mixed> $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']);
}

/**
* @param array<string, mixed> $options
*/
public function waitForDetached(array $options = []): void
{
$timeout = $this->extractTimeout($options);
$this->waitForCondition(
fn () => !$this->isAttached(),
$timeout,
'Element still attached'
);
$this->waitFor([...$options, 'state' => 'detached']);
}

/**
* @param array<string, mixed> $options
*/
public function waitForVisible(array $options = []): void
{
$timeout = $this->extractTimeout($options);
$this->waitForCondition(
fn () => $this->isVisible(),
$timeout,
'Element not visible'
);
$this->waitFor([...$options, 'state' => 'visible']);
}

/**
* @param array<string, mixed> $options
*/
public function waitForHidden(array $options = []): void
{
$timeout = $this->extractTimeout($options);
$this->waitForCondition(
fn () => $this->isHidden(),
$timeout,
'Element still visible'
);
$this->waitFor([...$options, 'state' => 'hidden']);
}

/**
Expand All @@ -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'
);
Expand All @@ -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)
);
Expand Down
122 changes: 107 additions & 15 deletions src/Transport/JsonRpc/JsonRpcTransport.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>
*/
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;
Expand Down Expand Up @@ -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');
Expand All @@ -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<string, mixed> $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);
}

/**
Expand Down
Loading
Loading