diff --git a/src/Api/Concerns/InteractsWithElements.php b/src/Api/Concerns/InteractsWithElements.php index 24566e7e..b77d7df0 100644 --- a/src/Api/Concerns/InteractsWithElements.php +++ b/src/Api/Concerns/InteractsWithElements.php @@ -100,7 +100,7 @@ public function hover(string $selector): self /** * Right-click the element matching the given selector. */ - public function rightClick(string $text): Webpage + public function rightClick(string $text): self { $this->guessLocator($text)->click([ 'button' => 'right', diff --git a/src/Api/Concerns/MakesConsoleAssertions.php b/src/Api/Concerns/MakesConsoleAssertions.php index 0ac7a8f2..36cad259 100644 --- a/src/Api/Concerns/MakesConsoleAssertions.php +++ b/src/Api/Concerns/MakesConsoleAssertions.php @@ -20,7 +20,7 @@ trait MakesConsoleAssertions /** * Asserts there are no console logs or JavaScript errors on the page. */ - public function assertNoSmoke(): Webpage + public function assertNoSmoke(): self { $this->assertNoConsoleLogs(); $this->assertNoJavaScriptErrors(); @@ -31,7 +31,7 @@ public function assertNoSmoke(): Webpage /** * Asserts there are no broken images on the page. */ - public function assertNoBrokenImages(): Webpage + public function assertNoBrokenImages(): self { $this->page->waitForLoadState('load'); @@ -50,7 +50,7 @@ public function assertNoBrokenImages(): Webpage /** * Asserts there are no missing images on the page. */ - public function assertNoMissingImages(): Webpage + public function assertNoMissingImages(): self { return $this->assertNoBrokenImages(); } @@ -58,7 +58,7 @@ public function assertNoMissingImages(): Webpage /** * Asserts there are no console logs on the page. */ - public function assertNoConsoleLogs(): Webpage + public function assertNoConsoleLogs(): self { $consoleLogs = $this->page->consoleLogs(); @@ -75,7 +75,7 @@ public function assertNoConsoleLogs(): Webpage /** * Asserts there are no JavaScript errors on the page. */ - public function assertNoJavaScriptErrors(): Webpage + public function assertNoJavaScriptErrors(): self { $javaScriptErrors = $this->page->javaScriptErrors(); @@ -92,7 +92,7 @@ public function assertNoJavaScriptErrors(): Webpage /** * Asserts the accessibility of the page. */ - public function assertNoAccessibilityIssues(int $level = 1): Webpage + public function assertNoAccessibilityIssues(int $level = 1): self { $this->page->waitForLoadState('networkidle'); $this->page->waitForFunction('document.readyState === "complete"'); diff --git a/src/Api/Concerns/MakesElementAssertions.php b/src/Api/Concerns/MakesElementAssertions.php index 0270add7..3e8f5d47 100644 --- a/src/Api/Concerns/MakesElementAssertions.php +++ b/src/Api/Concerns/MakesElementAssertions.php @@ -16,7 +16,7 @@ trait MakesElementAssertions /** * Assert that the page title matches the given text. */ - public function assertTitle(string|int|float $title): Webpage + public function assertTitle(string|int|float $title): self { $title = (string) $title; @@ -28,7 +28,7 @@ public function assertTitle(string|int|float $title): Webpage /** * Assert that the page title contains the given text. */ - public function assertTitleContains(string|int|float $title): Webpage + public function assertTitleContains(string|int|float $title): self { $title = (string) $title; @@ -42,13 +42,11 @@ public function assertTitleContains(string|int|float $title): Webpage /** * Assert that the given text is present on the page. */ - public function assertSee(string|int|float $text): Webpage + public function assertSee(string|int|float $text): self { $text = (string) $text; - $locator = $this->page->unstrict( - fn () => $this->page->getByText($text), - ); + $locator = $this->getTextLocator($text); foreach ($locator->all() as $element) { if ($element->isVisible()) { @@ -66,13 +64,11 @@ public function assertSee(string|int|float $text): Webpage /** * Assert that the given text is not present on the page. */ - public function assertDontSee(string|int|float $text): Webpage + public function assertDontSee(string|int|float $text): self { $text = (string) $text; - $locator = $this->page->unstrict( - fn () => $this->page->getByText($text), - ); + $locator = $this->getTextLocator($text); foreach ($locator->all() as $element) { if ($element->isVisible()) { @@ -90,7 +86,7 @@ public function assertDontSee(string|int|float $text): Webpage /** * Assert that the given text is present within the selector. */ - public function assertSeeIn(string $selector, string|int|float $text): Webpage + public function assertSeeIn(string $selector, string|int|float $text): self { $text = (string) $text; @@ -104,7 +100,7 @@ public function assertSeeIn(string $selector, string|int|float $text): Webpage /** * Assert that the given text is not present within the selector. */ - public function assertDontSeeIn(string $selector, string|int|float $text): Webpage + public function assertDontSeeIn(string $selector, string|int|float $text): self { $text = (string) $text; @@ -118,7 +114,7 @@ public function assertDontSeeIn(string $selector, string|int|float $text): Webpa /** * Assert that any text is present within the selector. */ - public function assertSeeAnythingIn(string $selector): Webpage + public function assertSeeAnythingIn(string $selector): self { $text = $this->guessLocator($selector)->textContent(); @@ -130,7 +126,7 @@ public function assertSeeAnythingIn(string $selector): Webpage /** * Assert that no text is present within the selector. */ - public function assertSeeNothingIn(string $selector): Webpage + public function assertSeeNothingIn(string $selector): self { $text = $this->guessLocator($selector)->textContent(); @@ -142,7 +138,7 @@ public function assertSeeNothingIn(string $selector): Webpage /** * Assert that a given element is present a given amount of times. */ - public function assertCount(string $selector, int $expected): Webpage + public function assertCount(string $selector, int $expected): self { $count = $this->guessLocator($selector)->count(); expect($count)->toBe($expected, "Expected to find {$expected} elements matching [{$selector}] on the page initially with the url [{$this->initialUrl}], but found {$count}."); @@ -153,7 +149,7 @@ public function assertCount(string $selector, int $expected): Webpage /** * Assert that the given JavaScript expression evaluates to the given value. */ - public function assertScript(string $expression, mixed $expected = true): Webpage + public function assertScript(string $expression, mixed $expected = true): self { if (! Str::contains($expression, ['===', '!==', '==', '!=', '>', '<', '>=', '<=', '&&', '||']) && ! Str::startsWith($expression, 'return ') && ! Str::startsWith($expression, 'function')) { $expression = "function() { return {$expression}; }"; @@ -190,7 +186,7 @@ public function assertScript(string $expression, mixed $expected = true): Webpag /** * Assert that the given source code is present on the page. */ - public function assertSourceHas(string $code): Webpage + public function assertSourceHas(string $code): self { $content = $this->page->content(); $message = "Expected page source to contain [{$code}] on the page initially with the url [{$this->initialUrl}], but it was not found."; @@ -202,7 +198,7 @@ public function assertSourceHas(string $code): Webpage /** * Assert that the given source code is not present on the page. */ - public function assertSourceMissing(string $code): Webpage + public function assertSourceMissing(string $code): self { $content = $this->page->content(); $message = "Expected page source not to contain [{$code}] on the page initially with the url [{$this->initialUrl}], but it was found."; @@ -214,7 +210,7 @@ public function assertSourceMissing(string $code): Webpage /** * Assert that the given link is present on the page. */ - public function assertSeeLink(string $link): Webpage + public function assertSeeLink(string $link): self { $locator = $this->guessLocator($link); @@ -226,7 +222,7 @@ public function assertSeeLink(string $link): Webpage /** * Assert that the given link is not present on the page. */ - public function assertDontSeeLink(string $link): Webpage + public function assertDontSeeLink(string $link): self { $locator = $this->guessLocator($link); @@ -238,7 +234,7 @@ public function assertDontSeeLink(string $link): Webpage /** * Assert that the given checkbox is checked. */ - public function assertChecked(string $field, string|int|float|null $value = null): Webpage + public function assertChecked(string $field, string|int|float|null $value = null): self { $value = $value !== null ? (string) $value : null; @@ -251,7 +247,7 @@ public function assertChecked(string $field, string|int|float|null $value = null /** * Assert that the given checkbox is not checked. */ - public function assertNotChecked(string $field, string|int|float|null $value = null): Webpage + public function assertNotChecked(string $field, string|int|float|null $value = null): self { $value = $value !== null ? (string) $value : null; @@ -264,7 +260,7 @@ public function assertNotChecked(string $field, string|int|float|null $value = n /** * Assert that the given checkbox is in an indeterminate state. */ - public function assertIndeterminate(string $field, string|int|float|null $value = null): Webpage + public function assertIndeterminate(string $field, string|int|float|null $value = null): self { $value = $value !== null ? (string) $value : null; @@ -289,7 +285,7 @@ public function assertIndeterminate(string $field, string|int|float|null $value /** * Assert that the given radio field is selected. */ - public function assertRadioSelected(string $field, string|int|float $value): Webpage + public function assertRadioSelected(string $field, string|int|float $value): self { $value = (string) $value; @@ -301,7 +297,7 @@ public function assertRadioSelected(string $field, string|int|float $value): Web /** * Assert that the given radio field is not selected. */ - public function assertRadioNotSelected(string $field, string|int|float|null $value = null): Webpage + public function assertRadioNotSelected(string $field, string|int|float|null $value = null): self { $value = $value !== null ? (string) $value : null; @@ -332,7 +328,7 @@ public function assertRadioNotSelected(string $field, string|int|float|null $val /** * Assert that the given dropdown has the given value selected. */ - public function assertSelected(string $field, string|int|float $value): Webpage + public function assertSelected(string $field, string|int|float $value): self { $value = (string) $value; @@ -347,7 +343,7 @@ public function assertSelected(string $field, string|int|float $value): Webpage /** * Assert that the given dropdown does not have the given value selected. */ - public function assertNotSelected(string $field, string|int|float $value): Webpage + public function assertNotSelected(string $field, string|int|float $value): self { $value = (string) $value; @@ -362,7 +358,7 @@ public function assertNotSelected(string $field, string|int|float $value): Webpa /** * Assert that the element matching the given selector has the given value. */ - public function assertValue(string $field, string|int|float $value): Webpage + public function assertValue(string $field, string|int|float $value): self { $value = (string) $value; @@ -376,7 +372,7 @@ public function assertValue(string $field, string|int|float $value): Webpage /** * Assert that the element matching the given selector does not have the given value. */ - public function assertValueIsNot(string $selector, string|int|float $value): Webpage + public function assertValueIsNot(string $selector, string|int|float $value): self { $value = (string) $value; @@ -389,7 +385,7 @@ public function assertValueIsNot(string $selector, string|int|float $value): Web /** * Assert that the element matching the given selector has the given value in the provided attribute. */ - public function assertAttribute(string $selector, string $attribute, string|int|float $value): Webpage + public function assertAttribute(string $selector, string $attribute, string|int|float $value): self { $value = (string) $value; @@ -402,7 +398,7 @@ public function assertAttribute(string $selector, string $attribute, string|int| /** * Assert that the element matching the given selector is missing the provided attribute. */ - public function assertAttributeMissing(string $selector, string $attribute): Webpage + public function assertAttributeMissing(string $selector, string $attribute): self { $actual = $this->guessLocator($selector)->getAttribute($attribute); expect($actual)->toBeNull("Expected element [{$selector}] not to have attribute [{$attribute}] on the page initially with the url [{$this->initialUrl}], but it had value [{$actual}]."); @@ -413,7 +409,7 @@ public function assertAttributeMissing(string $selector, string $attribute): Web /** * Assert that the element matching the given selector contains the given value in the provided attribute. */ - public function assertAttributeContains(string $selector, string $attribute, string|int|float $value): Webpage + public function assertAttributeContains(string $selector, string $attribute, string|int|float $value): self { $value = (string) $value; @@ -430,7 +426,7 @@ public function assertAttributeContains(string $selector, string $attribute, str /** * Assert that the element matching the given selector does not contain the given value in the provided attribute. */ - public function assertAttributeDoesntContain(string $selector, string $attribute, string|int|float $value): Webpage + public function assertAttributeDoesntContain(string $selector, string $attribute, string|int|float $value): self { $value = (string) $value; @@ -449,7 +445,7 @@ public function assertAttributeDoesntContain(string $selector, string $attribute /** * Assert that the element matching the given selector has the given value in the provided aria attribute. */ - public function assertAriaAttribute(string $selector, string $attribute, string|int|float $value): Webpage + public function assertAriaAttribute(string $selector, string $attribute, string|int|float $value): self { $value = (string) $value; @@ -459,7 +455,7 @@ public function assertAriaAttribute(string $selector, string $attribute, string| /** * Assert that the element matching the given selector has the given value in the provided data attribute. */ - public function assertDataAttribute(string $selector, string $attribute, string|int|float $value): Webpage + public function assertDataAttribute(string $selector, string $attribute, string|int|float $value): self { $value = (string) $value; @@ -469,7 +465,7 @@ public function assertDataAttribute(string $selector, string $attribute, string| /** * Assert that the element matching the given selector is visible. */ - public function assertVisible(string $selector): Webpage + public function assertVisible(string $selector): self { $locator = $this->guessLocator($selector); @@ -481,7 +477,7 @@ public function assertVisible(string $selector): Webpage /** * Assert that the element matching the given selector is present. */ - public function assertPresent(string $selector): Webpage + public function assertPresent(string $selector): self { $count = $this->guessLocator($selector)->count(); expect($count)->toBeGreaterThan(0, "Expected element [{$selector}] to be present in the DOM on the page initially with the url [{$this->initialUrl}], but it was not found."); @@ -492,7 +488,7 @@ public function assertPresent(string $selector): Webpage /** * Assert that the element matching the given selector is not present in the source. */ - public function assertNotPresent(string $selector): Webpage + public function assertNotPresent(string $selector): self { $count = $this->guessLocator($selector)->count(); expect($count)->toBe(0, "Expected element [{$selector}] not to be present in the DOM on the page initially with the url [{$this->initialUrl}], but it was found."); @@ -503,7 +499,7 @@ public function assertNotPresent(string $selector): Webpage /** * Assert that the element matching the given selector is not visible. */ - public function assertMissing(string $selector): Webpage + public function assertMissing(string $selector): self { $locator = $this->guessLocator($selector); @@ -515,7 +511,7 @@ public function assertMissing(string $selector): Webpage /** * Assert that the given field is enabled. */ - public function assertEnabled(string $field): Webpage + public function assertEnabled(string $field): self { expect($this->guessLocator($field)->isEnabled())->toBeTrue("Expected field [{$field}] to be enabled on the page initially with the url [{$this->initialUrl}], but it was disabled."); @@ -525,7 +521,7 @@ public function assertEnabled(string $field): Webpage /** * Assert that the given field is disabled. */ - public function assertDisabled(string $field): Webpage + public function assertDisabled(string $field): self { expect($this->guessLocator($field)->isDisabled())->toBeTrue("Expected field [{$field}] to be disabled on the page initially with the url [{$this->initialUrl}], but it was enabled."); @@ -535,7 +531,7 @@ public function assertDisabled(string $field): Webpage /** * Assert that the given button is enabled. */ - public function assertButtonEnabled(string $button): Webpage + public function assertButtonEnabled(string $button): self { $selector = $this->guessLocator($button); @@ -547,7 +543,7 @@ public function assertButtonEnabled(string $button): Webpage /** * Assert that the given button is disabled. */ - public function assertButtonDisabled(string $button): Webpage + public function assertButtonDisabled(string $button): self { $selector = $this->guessLocator($button); @@ -561,7 +557,7 @@ public function assertButtonDisabled(string $button): Webpage * * @deprecated Use `assertSee` instead. */ - public function waitForText(string|int|float $text): Webpage + public function waitForText(string|int|float $text): self { $text = (string) $text; diff --git a/src/Api/ScopedWebpage.php b/src/Api/ScopedWebpage.php new file mode 100644 index 00000000..a7ddf77c --- /dev/null +++ b/src/Api/ScopedWebpage.php @@ -0,0 +1,88 @@ +page->content()); + } + + /** + * Submits the first form found on the page. + */ + public function submit(): self + { + $this->guessLocator('[type="submit"]')->click(); + + return $this; + } + + /** + * Gets the page instance. + */ + public function value(string $selector): string + { + return $this->guessLocator($selector)->inputValue(); + } + + /** + * Limits the scope of subsequent interactions to within a specific element. + */ + public function within(string $selector, callable $callback): self + { + $nestedScope = $this->scope.' '.$selector; + $scopedWebpage = new self($this->page, $this->initialUrl, $nestedScope); + + $callback($scopedWebpage); + + return $this; + } + + /** + * Gets the locator for the given selector. + */ + private function guessLocator(string $selector, ?string $value = null): Locator + { + return (new GuessLocator($this->page, $this->scope))->for($selector, $value); + } + + /** + * Gets the locator for the given text. + */ + private function getTextLocator(string $text): Locator + { + return $this->page->unstrict( + fn (): Locator => $this->page->locator($this->scope)->getByText($text) + ); + } +} diff --git a/src/Api/Webpage.php b/src/Api/Webpage.php index d42876a7..485b43ae 100644 --- a/src/Api/Webpage.php +++ b/src/Api/Webpage.php @@ -95,6 +95,18 @@ public function value(string $selector): string return $this->guessLocator($selector)->inputValue(); } + /** + * Limits the scope of subsequent interactions to within a specific element. + */ + public function within(string $selector, callable $callback): self + { + $scopedWebpage = new ScopedWebpage($this->page, $this->initialUrl, $selector); + + $callback($scopedWebpage); + + return $this; + } + /** * Gets the locator for the given selector. */ @@ -102,4 +114,14 @@ private function guessLocator(string $selector, ?string $value = null): Locator { return (new GuessLocator($this->page))->for($selector, $value); } + + /** + * Gets the locator for the given text. + */ + private function getTextLocator(string $text): Locator + { + return $this->page->unstrict( + fn (): Locator => $this->page->getByText($text), + ); + } } diff --git a/src/Support/GuessLocator.php b/src/Support/GuessLocator.php index b9abb555..f80ef857 100644 --- a/src/Support/GuessLocator.php +++ b/src/Support/GuessLocator.php @@ -18,6 +18,7 @@ */ public function __construct( private Page $page, + private ?string $scope = null, ) { // } @@ -32,16 +33,18 @@ public function for(string $selector, ?string $value = null): Locator $selector .= sprintf('[value=%s]', Selector::escapeForAttributeSelectorOrRegex($value, true)); } - return $this->page->locator($selector); + return $this->applyScopeToLocator($this->page->locator($selector)); } 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]", - ), + return $this->applyScopeToLocator( + $this->page->unstrict( + fn (): Locator => $this->page->locator( + "[data-testid=$id], [data-test=$id]", + ), + ) ); } @@ -52,8 +55,10 @@ public function for(string $selector, ?string $value = null): Locator $formattedSelector .= sprintf('[value=%s]', Selector::escapeForAttributeSelectorOrRegex($value, true)); } - $locator = $this->page->unstrict( - fn (): Locator => $this->page->locator($formattedSelector), + $locator = $this->applyScopeToLocator( + $this->page->unstrict( + fn (): Locator => $this->page->locator($formattedSelector), + ) ); if ($locator->count() > 0) { @@ -67,8 +72,24 @@ public function for(string $selector, ?string $value = null): Locator ); } - return $this->page->unstrict( - fn (): Locator => $this->page->getByText($selector, true), + return $this->applyScopeToLocator( + $this->page->unstrict( + fn (): Locator => $this->page->getByText($selector, true), + ) ); } + + /** + * Applies scope to a locator if scope is defined. + */ + private function applyScopeToLocator(Locator $locator): Locator + { + if ($this->scope === null) { + return $locator; + } + + $scopedParent = $this->page->locator($this->scope); + + return $scopedParent->locator($locator->selector()); + } } diff --git a/tests/Browser/Webpage/WithinTest.php b/tests/Browser/Webpage/WithinTest.php new file mode 100644 index 00000000..8f821bf0 --- /dev/null +++ b/tests/Browser/Webpage/WithinTest.php @@ -0,0 +1,187 @@ + ' + +
+ + Content Link +
+ '); + + Route::get('/sidebar', fn (): string => 'Sidebar Page'); + Route::get('/content', fn (): string => 'Content Page'); + + $page = visit('/'); + + $page->within('#sidebar', function ($page): void { + $page->click('Sidebar Button'); + }) + ->within('#sidebar', function ($page): void { + $page->click('Sidebar Link'); + }) + ->within('#content', function ($page): void { + $page->assertDontSee('Sidebar Link'); + }) + ->assertUrlIs(url('/sidebar')) + ->assertSee('Sidebar Page'); +}); + +it('can type in input within a scoped selector', function (): void { + Route::get('/', fn (): string => ' +
+ + +
+
+ + +
+ '); + + $page = visit('/'); + + $page->within('#form1', function ($page): void { + $page->type('username', 'john_doe'); + }); + + $page->within('#form2', function ($page): void { + $page->type('username', 'john@example.com'); + }); + + expect($page->value('#form1 input[name="username"]'))->toBe('john_doe'); + expect($page->value('#form2 input[name="username"]'))->toBe('john@example.com'); +}); + +it('can assert text within a scoped selector', function (): void { + Route::get('/', fn (): string => ' + + + '); + + $page = visit('/'); + + $page->within('#header', function ($page): void { + $page->assertSee('Welcome'); + $page->assertSee('Header content'); + $page->assertDontSee('Footer content'); + }); + + $page->within('#footer', function ($page): void { + $page->assertSee('Contact'); + $page->assertSee('Footer content'); + $page->assertDontSee('Header content'); + }); +}); + +it('can use css selectors within scope', function (): void { + Route::get('/', fn (): string => ' +
+
+ +
+
+ +
+
+
+ +
+ '); + + $page = visit('/'); + + $page->within('.container', function ($page): void { + $page->click('.item:first-child .btn'); + }); + + $page->within('.container .item:first-child', function ($page): void { + $page->assertSee('Clicked'); + }); +}); + +it('works with data-test selectors within scope', function (): void { + Route::get('/', fn (): string => ' +
+ +
+
+ +
+ '); + + $page = visit('/'); + + $page->within('[data-testid="sidebar"]', function ($page): void { + $page->click('@action-btn')->assertSee('Sidebar Clicked'); + }); + $page->within('[data-testid="content"]', function ($page): void { + $page->assertSee('Content Action')->assertDontSee('Content Clicked'); + $page->click('@action-btn')->assertSee('Content Clicked'); + }); +}); + +it('works with nested scopes', function (): void { + Route::get('/', fn (): string => ' +
+
+ +

Nested Text

+
+
+ '); + + $page = visit('/'); + + $page->within('#outer', function ($page): void { + $page->within('.inner', function ($innerBrowser): void { + $innerBrowser->assertSee('Nested Text'); + $innerBrowser->click('#inner-button')->assertSee('Inner button clicked'); + }); + }); +}); + +it('handles form interactions within scope', function (): void { + Route::get('/', fn (): string => ' +
+ + + + + + + +
+ '); + + $page = visit('/'); + + $page->within('#login-form', function ($page): void { + $page->type('email', 'user@example.com') + ->type('password', 'secret') + ->select('role', 'admin') + ->check('remember') + ->radio('theme', 'dark') + ->press('Login'); + }); + + expect($page->value('#login-form input[name="email"]'))->toBe('user@example.com'); + expect($page->value('#login-form input[name="password"]'))->toBe('secret'); + expect($page->value('#login-form select[name="role"]'))->toBe('admin'); +});