From d6b5ecb6cf52b413031b51b1cecdcb96c386f3a8 Mon Sep 17 00:00:00 2001 From: Mr Ant Date: Sun, 14 Jun 2026 09:30:57 +0100 Subject: [PATCH 1/3] Avoid retrying side-effecting browser actions --- src/Api/AwaitableWebpage.php | 54 ++++++++++++++++++++++++++---------- 1 file changed, 39 insertions(+), 15 deletions(-) diff --git a/src/Api/AwaitableWebpage.php b/src/Api/AwaitableWebpage.php index 71ac96b1..005ac8f5 100644 --- a/src/Api/AwaitableWebpage.php +++ b/src/Api/AwaitableWebpage.php @@ -17,6 +17,27 @@ */ final readonly class AwaitableWebpage { + private const array NON_RETRYABLE_ACTION_METHODS = [ + 'click', + 'press', + 'pressAndWaitFor', + 'keys', + 'type', + 'typeSlowly', + 'fill', + 'hover', + 'rightClick', + 'select', + 'append', + 'clear', + 'radio', + 'check', + 'uncheck', + 'attach', + 'drag', + 'withKeyDown', + ]; + /** * Creates a new awaitable webpage instance. * @@ -34,8 +55,6 @@ public function __construct( } /** - * Awaits for the given method to assert true or fail. - * * @param array $arguments */ public function __call(string $name, array $arguments): mixed @@ -43,13 +62,16 @@ public function __call(string $name, array $arguments): mixed $webpage = new Webpage($this->page, $this->initialUrl); try { - if ( - in_array($name, $this->nonAwaitableMethods, true) - || Playwright::timeout() <= 1000 - ) { - // @phpstan-ignore-next-line - $result = $webpage->{$name}(...$arguments); + if ($this->shouldRunOnce($name)) { + // Actions should be attempted once. + // Playwright itself will wait for actionability up to the configured timeout. + $result = Playwright::usingTimeout( + Playwright::timeout(), + // @phpstan-ignore-next-line + fn () => $webpage->{$name}(...$arguments), + ); } else { + // Assertions/read expectations may be retried until the browser timeout expires. $result = Execution::instance()->waitForExpectation( // @phpstan-ignore-next-line fn () => $webpage->{$name}(...$arguments), @@ -60,7 +82,7 @@ public function __call(string $name, array $arguments): mixed try { $browserException = BrowserExpectationFailedException::from($this->page, $e); - } catch (Throwable) { // @phpstan-ignore-line + } catch (Throwable) { throw $e; } @@ -69,16 +91,18 @@ public function __call(string $name, array $arguments): mixed ServerManager::instance()->http()->throwLastThrowableIfNeeded(); - return $result === $webpage - ? $this - : $result; + return $result === $webpage ? $this : $result; } - /** - * Return the page instance. - */ public function page(): Page { return $this->page; } + + private function shouldRunOnce(string $name): bool + { + return in_array($name, $this->nonAwaitableMethods, true) + || in_array($name, self::NON_RETRYABLE_ACTION_METHODS, true) + || Playwright::timeout() <= 1000; + } } From f4f50e2b32b4546390a5bd03df1c25cc97d1704a Mon Sep 17 00:00:00 2001 From: Mr Ant Date: Sat, 11 Jul 2026 04:49:31 +0100 Subject: [PATCH 2/3] package.json --- package-lock.json | 16 ++++++++-------- package.json | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0e6174e6..9088d3af 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,7 +5,7 @@ "packages": { "": { "dependencies": { - "playwright": "^1.59.1" + "playwright": "^1.60.0" } }, "node_modules/fsevents": { @@ -23,12 +23,12 @@ } }, "node_modules/playwright": { - "version": "1.59.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", - "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.59.1" + "playwright-core": "1.60.0" }, "bin": { "playwright": "cli.js" @@ -41,9 +41,9 @@ } }, "node_modules/playwright-core": { - "version": "1.59.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", - "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" diff --git a/package.json b/package.json index 8a8fbea3..f2eab146 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { "dependencies": { - "playwright": "^1.59.1" + "playwright": "^1.60.0" } } From 40baac1c3493f6eba2dd02ab8fa74d822726d98d Mon Sep 17 00:00:00 2001 From: Mr Ant Date: Sat, 11 Jul 2026 05:41:40 +0100 Subject: [PATCH 3/3] Resolve Locator and Single Action --- src/Api/AwaitableWebpage.php | 5 +- src/Api/Concerns/InteractsWithElements.php | 22 +- src/Api/Webpage.php | 23 +- src/Support/GuessLocator.php | 243 +++++++++++-- .../Browser/Webpage/LocatorResolutionTest.php | 319 ++++++++++++++++++ 5 files changed, 574 insertions(+), 38 deletions(-) create mode 100644 tests/Browser/Webpage/LocatorResolutionTest.php diff --git a/src/Api/AwaitableWebpage.php b/src/Api/AwaitableWebpage.php index 005ac8f5..4c774da4 100644 --- a/src/Api/AwaitableWebpage.php +++ b/src/Api/AwaitableWebpage.php @@ -21,11 +21,10 @@ 'click', 'press', 'pressAndWaitFor', - 'keys', + 'submit', 'type', 'typeSlowly', 'fill', - 'hover', 'rightClick', 'select', 'append', @@ -34,8 +33,6 @@ 'check', 'uncheck', 'attach', - 'drag', - 'withKeyDown', ]; /** diff --git a/src/Api/Concerns/InteractsWithElements.php b/src/Api/Concerns/InteractsWithElements.php index 24566e7e..c8a8f81d 100644 --- a/src/Api/Concerns/InteractsWithElements.php +++ b/src/Api/Concerns/InteractsWithElements.php @@ -16,7 +16,7 @@ trait InteractsWithElements */ public function click(string $text): self { - $this->guessLocator($text)->click(); + $this->guessClickableLocator($text)->click(); return $this; } @@ -62,7 +62,7 @@ public function keys(string $selector, array|string $keys): self */ public function type(string $field, string $value): self { - $this->guessLocator($field)->fill($value); + $this->guessFieldLocator($field)->fill($value); return $this; } @@ -74,7 +74,7 @@ public function typeSlowly(string $field, string $value, int $delay = 100): self { $options = ['delay' => $delay]; - $this->guessLocator($field)->type($value, $options); + $this->guessFieldLocator($field)->type($value, $options); return $this; } @@ -102,7 +102,7 @@ public function hover(string $selector): self */ public function rightClick(string $text): Webpage { - $this->guessLocator($text)->click([ + $this->guessClickableLocator($text)->click([ 'button' => 'right', ]); @@ -116,7 +116,7 @@ public function rightClick(string $text): Webpage */ public function select(string $field, array|string|int $option): self { - $this->guessLocator($field)->selectOption($option); + $this->guessSelectableLocator($field)->selectOption($option); return $this; } @@ -126,7 +126,7 @@ public function select(string $field, array|string|int $option): self */ public function append(string $field, string $value): self { - $locator = $this->guessLocator($field); + $locator = $this->guessFieldLocator($field); $currentValue = $locator->inputValue(); @@ -140,7 +140,7 @@ public function append(string $field, string $value): self */ public function clear(string $field): self { - $this->guessLocator($field)->clear(); + $this->guessFieldLocator($field)->clear(); return $this; } @@ -150,7 +150,7 @@ public function clear(string $field): self */ public function radio(string $field, string $value): self { - $this->guessLocator($field, $value)->click(); + $this->guessCheckableLocator($field, $value)->click(); return $this; } @@ -160,7 +160,7 @@ public function radio(string $field, string $value): self */ public function check(string $field, ?string $value = null): self { - $this->guessLocator($field, $value)->check(); + $this->guessCheckableLocator($field, $value)->check(); return $this; } @@ -170,7 +170,7 @@ public function check(string $field, ?string $value = null): self */ public function uncheck(string $field, ?string $value = null): self { - $this->guessLocator($field, $value)->uncheck(); + $this->guessCheckableLocator($field, $value)->uncheck(); return $this; } @@ -180,7 +180,7 @@ public function uncheck(string $field, ?string $value = null): self */ public function attach(string $field, string $path): self { - $this->guessLocator($field)->setInputFiles($path); + $this->guessFieldLocator($field)->setInputFiles($path); return $this; } diff --git a/src/Api/Webpage.php b/src/Api/Webpage.php index d42876a7..2b32ea56 100644 --- a/src/Api/Webpage.php +++ b/src/Api/Webpage.php @@ -4,7 +4,6 @@ namespace Pest\Browser\Api; -use Pest\Browser\Execution; use Pest\Browser\Playwright\Locator; use Pest\Browser\Playwright\Page; use Pest\Browser\Support\GuessLocator; @@ -74,7 +73,7 @@ public function url(): string */ public function submit(): self { - $this->guessLocator('[type="submit"]')->click(); + $this->guessClickableLocator('[type="submit"]')->click(); return $this; } @@ -102,4 +101,24 @@ private function guessLocator(string $selector, ?string $value = null): Locator { return (new GuessLocator($this->page))->for($selector, $value); } + + private function guessClickableLocator(string $selector): Locator + { + return (new GuessLocator($this->page))->forClickable($selector); + } + + private function guessFieldLocator(string $selector): Locator + { + return (new GuessLocator($this->page))->forField($selector); + } + + private function guessCheckableLocator(string $selector, ?string $value = null): Locator + { + return (new GuessLocator($this->page))->forCheckable($selector, $value); + } + + private function guessSelectableLocator(string $selector): Locator + { + return (new GuessLocator($this->page))->forSelectable($selector); + } } diff --git a/src/Support/GuessLocator.php b/src/Support/GuessLocator.php index b9abb555..90e6caaf 100644 --- a/src/Support/GuessLocator.php +++ b/src/Support/GuessLocator.php @@ -4,8 +4,10 @@ namespace Pest\Browser\Support; +use Pest\Browser\Execution; use Pest\Browser\Playwright\Locator; use Pest\Browser\Playwright\Page; +use Pest\Browser\Playwright\Playwright; use PHPUnit\Framework\ExpectationFailedException; /** @@ -27,30 +29,16 @@ public function __construct( */ public function for(string $selector, ?string $value = null): Locator { - if (Selector::isExplicit($selector)) { - if ($value !== null) { - $selector .= sprintf('[value=%s]', Selector::escapeForAttributeSelectorOrRegex($value, true)); - } - - return $this->page->locator($selector); + if (($locator = $this->explicitLocator($selector, $value)) instanceof Locator) { + return $locator; } - if (Selector::isDataTest($selector)) { - $id = Selector::escapeForAttributeSelectorOrRegex(str_replace('@', '', $selector), true); - - return $this->page->unstrict( - fn (): Locator => $this->page->locator( - "[data-testid=$id], [data-test=$id]", - ), - ); + if (($locator = $this->dataTestLocator($selector)) instanceof Locator) { + return $locator; } foreach (['[id="%s"]', '[name="%s"]'] as $format) { - $formattedSelector = sprintf($format, $selector); - - if ($value !== null) { - $formattedSelector .= sprintf('[value=%s]', Selector::escapeForAttributeSelectorOrRegex($value, true)); - } + $formattedSelector = sprintf($format, $selector).$this->valueSelector($value); $locator = $this->page->unstrict( fn (): Locator => $this->page->locator($formattedSelector), @@ -62,8 +50,8 @@ public function for(string $selector, ?string $value = null): Locator } if ($value !== null) { - return throw new ExpectationFailedException( - sprintf('Selector [%s] does not match any element.', $selector) + throw new ExpectationFailedException( + sprintf('Selector [%s] does not match any element.', $selector), ); } @@ -71,4 +59,217 @@ public function for(string $selector, ?string $value = null): Locator fn (): Locator => $this->page->getByText($selector, true), ); } + + public function forClickable(string $selector): Locator + { + if (($locator = $this->explicitLocator($selector)) instanceof Locator) { + return $locator; + } + + if (($locator = $this->dataTestLocator($selector)) instanceof Locator) { + return $locator; + } + + return $this->resolveLocator($selector, 'clickable element', [ + fn (): Locator => $this->page->locator(sprintf('[id="%s"]', $selector)), + fn (): Locator => $this->page->locator(sprintf('[name="%s"]', $selector)), + fn (): Locator => $this->page->getByRole('button', [ + 'name' => $selector, + 'exact' => true, + ]), + fn (): Locator => $this->page->getByRole('link', [ + 'name' => $selector, + 'exact' => true, + ]), + fn (): Locator => $this->page->getByText($selector, true), + ]); + } + + public function forField(string $selector): Locator + { + if (($locator = $this->explicitLocator($selector)) instanceof Locator) { + return $locator; + } + + if (($locator = $this->dataTestLocator($selector)) instanceof Locator) { + return $locator; + } + + return $this->resolveLocator($selector, 'field control', [ + fn (): Locator => $this->page->locator($this->fieldSelector('id', $selector)), + fn (): Locator => $this->page->locator($this->fieldSelector('name', $selector)), + fn (): Locator => $this->page->getByLabel($selector, true), + fn (): Locator => $this->page->getByPlaceholder($selector, true), + ]); + } + + public function forCheckable(string $selector, ?string $value = null): Locator + { + if (($locator = $this->explicitLocator($selector, $value)) instanceof Locator) { + return $locator; + } + + if (($locator = $this->dataTestLocator($selector, $value)) instanceof Locator) { + return $locator; + } + + $candidates = [ + fn (): Locator => $this->page->locator($this->checkableSelector('id', $selector, $value)), + fn (): Locator => $this->page->locator($this->checkableSelector('name', $selector, $value)), + fn (): Locator => $this->page->getByLabel($selector, true), + ]; + + if ($value === null) { + $candidates[] = fn (): Locator => $this->page->getByRole('checkbox', [ + 'name' => $selector, + 'exact' => true, + ]); + $candidates[] = fn (): Locator => $this->page->getByRole('radio', [ + 'name' => $selector, + 'exact' => true, + ]); + } + + return $this->resolveLocator($selector, 'checkable control', $candidates, $value); + } + + public function forSelectable(string $selector): Locator + { + if (($locator = $this->explicitLocator($selector)) instanceof Locator) { + return $locator; + } + + if (($locator = $this->dataTestLocator($selector)) instanceof Locator) { + return $locator; + } + + return $this->resolveLocator($selector, 'selectable control', [ + fn (): Locator => $this->page->locator(sprintf('select[id="%s"]', $selector)), + fn (): Locator => $this->page->locator(sprintf('select[name="%s"]', $selector)), + fn (): Locator => $this->page->getByLabel($selector, true), + ]); + } + + /** + * @param array $candidates + */ + private function resolveLocator( + string $selector, + string $category, + array $candidates, + ?string $value = null, + ): Locator { + $locator = $this->retryUntilTimeout( + /** + * @throws ExpectationFailedException + */ + function () use ($candidates): Locator { + foreach ($candidates as $candidate) { + $locator = $this->page->unstrict( + fn (): Locator => $candidate(), + ); + + if ($this->locatorExists($locator)) { + return $locator; + } + } + + throw new ExpectationFailedException('No candidate matched during this retry cycle.'); + }, + ); + + if ($locator instanceof Locator) { + return $locator; + } + + throw new ExpectationFailedException($this->missingLocatorMessage($selector, $category, $value)); + } + + /** + * @param callable(): Locator $callback + */ + private function retryUntilTimeout(callable $callback): ?Locator + { + $end = microtime(true) + (Playwright::timeout() / 1_000); + + do { + try { + return $callback(); + } catch (ExpectationFailedException) { + Execution::instance()->tick(); + } + } while (microtime(true) < $end); + + return null; + } + + private function locatorExists(Locator $locator): bool + { + return Playwright::usingTimeout(1, fn (): bool => $locator->count() > 0); + } + + private function explicitLocator(string $selector, ?string $value = null): ?Locator + { + if (! Selector::isExplicit($selector)) { + return null; + } + + return $this->page->locator($selector.$this->valueSelector($value)); + } + + private function dataTestLocator(string $selector, ?string $value = null): ?Locator + { + if (! Selector::isDataTest($selector)) { + return null; + } + + $id = Selector::escapeForAttributeSelectorOrRegex(str_replace('@', '', $selector), true); + + return $this->page->unstrict( + fn (): Locator => $this->page->locator( + sprintf( + ':is([data-testid=%1$s], [data-test=%1$s])%2$s', + $id, + $this->valueSelector($value), + ), + ), + ); + } + + private function fieldSelector(string $attribute, string $selector): string + { + return sprintf( + ':is(input, textarea, [contenteditable]:not([contenteditable="false"]))[%s="%s"]', + $attribute, + $selector, + ); + } + + private function checkableSelector(string $attribute, string $selector, ?string $value = null): string + { + return sprintf( + ':is(input[type="checkbox"], input[type="radio"])[%s="%s"]%s', + $attribute, + $selector, + $this->valueSelector($value), + ); + } + + private function valueSelector(?string $value): string + { + if ($value === null) { + return ''; + } + + return sprintf('[value=%s]', Selector::escapeForAttributeSelectorOrRegex($value, true)); + } + + private function missingLocatorMessage(string $selector, string $category, ?string $value = null): string + { + if ($value === null) { + return sprintf('No suitable %s was found for [%s].', $category, $selector); + } + + return sprintf('No suitable %s was found for [%s] with value [%s].', $category, $selector, $value); + } } diff --git a/tests/Browser/Webpage/LocatorResolutionTest.php b/tests/Browser/Webpage/LocatorResolutionTest.php new file mode 100644 index 00000000..28094442 --- /dev/null +++ b/tests/Browser/Webpage/LocatorResolutionTest.php @@ -0,0 +1,319 @@ + <<<'HTML' +
email
+ +
+ + + HTML); + + $page = visit('/'); + + $page + ->click('Reveal field') + ->fill('email', 'tony@example.com') + ->assertValue('email', 'tony@example.com'); + + expect($page->script('() => window.revealFieldClicks'))->toBe(1) + ->and($page->script('() => window.emailInputEvents'))->toBe(1); +}); + +it('waits for delayed field resolution by exact label and placeholder', function (string $selector): void { + Route::get('/', fn (): string => <<<'HTML' +
Email address
+
Enter email
+ +
+ + + HTML); + + $page = visit('/'); + + $page + ->click('Reveal field') + ->fill($selector, 'tony@example.com') + ->assertValue('#email-field', 'tony@example.com'); +})->with([ + 'label' => 'Email address', + 'placeholder' => 'Enter email', +]); + +it('waits for a delayed checkbox instead of falling back to visible text', function (): void { + Route::get('/', fn (): string => <<<'HTML' +
terms
+ +
+ + + HTML); + + $page = visit('/'); + + $page->click('Reveal checkbox')->check('terms'); + + expect($page->script('() => document.getElementById("terms-box").checked'))->toBeTrue() + ->and($page->script('() => window.revealCheckboxClicks'))->toBe(1) + ->and($page->script('() => window.checkboxChanges'))->toBe(1); + + $page->uncheck('terms'); + + expect($page->script('() => document.getElementById("terms-box").checked'))->toBeFalse() + ->and($page->script('() => window.checkboxChanges'))->toBe(2); +}); + +it('waits for delayed radio controls and respects the requested value', function (): void { + Route::get('/', fn (): string => <<<'HTML' +
contact_method
+ +
+ + + HTML); + + $page = visit('/'); + + $page->click('Reveal radios')->radio('contact_method', 'email'); + + expect($page->script('() => document.getElementById("contact-email").checked'))->toBeTrue() + ->and($page->script('() => document.getElementById("contact-phone").checked'))->toBeFalse(); +}); + +it('waits for a delayed select instead of falling back to visible text', function (): void { + Route::get('/', fn (): string => <<<'HTML' +
country
+ +
+ + + HTML); + + $page = visit('/'); + + $page->click('Reveal select')->select('country', 'Mexico'); + + expect($page->script('() => document.getElementById("country-select").value'))->toBe('mx') + ->and($page->script('() => window.selectChanges'))->toBe(1); +}); + +it('keeps assertion retries independent from action execution', function (): void { + Route::get('/', fn (): string => <<<'HTML' +
Loading...
+ + + HTML); + + visit('/')->assertSee('Loaded later'); +}); + +it('supports existing field selector forms', function (string $selector): void { + Route::get('/', fn (): string => <<<'HTML' +
+ + +
+ HTML); + + $page = visit('/'); + + $page->fill($selector, 'tony@example.com'); + + expect($page->value('#email-id'))->toBe('tony@example.com'); +})->with([ + 'explicit css' => '#email-id', + 'data test' => '@email-field', + 'id' => 'email-id', + 'name' => 'email_name', + 'label' => 'Email address', + 'placeholder' => 'Enter email', +]); + +it('supports existing checkable selector forms', function (string $selector, ?string $value = null): void { + Route::get('/', fn (): string => <<<'HTML' +
+ + + + + + + +
+ HTML); + + $page = visit('/'); + + $page->check($selector, $value); + + if ($value === null) { + expect($page->script('() => document.getElementById("terms-id").checked'))->toBeTrue(); + + return; + } + + expect($page->script('() => document.getElementById("contact-email").checked'))->toBeTrue() + ->and($page->script('() => document.getElementById("contact-sms").checked'))->toBeFalse(); +})->with([ + 'data test' => ['@terms-field', null], + 'id' => ['terms-id', null], + 'name' => ['terms_name', null], + 'label' => ['Accept terms', null], + 'name with value' => ['contact_method', 'email'], +]); + +it('supports existing select selector forms', function (string $selector): void { + Route::get('/', fn (): string => <<<'HTML' +
+ + +
+ HTML); + + $page = visit('/'); + + $page->select($selector, 'Canada'); + + expect($page->value('#country-id'))->toBe('ca'); +})->with([ + 'explicit css' => '#country-id', + 'data test' => '@country-field', + 'id' => 'country-id', + 'name' => 'country_name', + 'label' => 'Country', +]); + +it('fails clearly when no field control can be found', function (): void { + Route::get('/', fn (): string => '
email
'); + + visit('/')->fill('email', 'tony@example.com'); +})->throws(ExpectationFailedException::class, 'No suitable field control was found for [email].'); + +it('fails clearly when no checkable control can be found', function (): void { + Route::get('/', fn (): string => '
terms
'); + + visit('/')->check('terms'); +})->throws(ExpectationFailedException::class, 'No suitable checkable control was found for [terms].'); + +it('fails clearly when no selectable control can be found', function (): void { + Route::get('/', fn (): string => '
country
'); + + visit('/')->select('country', 'Canada'); +})->throws(ExpectationFailedException::class, 'No suitable selectable control was found for [country].');