diff --git a/CHANGELOG.md b/CHANGELOG.md index b3568dd..cad92ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,9 +8,15 @@ - `KeyboardInterface::insertText()` - `PageInterface::pause()` - `ResponseInterface::headerValue()` +- `expect($locator)->toBeAttached()` + +### Changed +- `Testing\Expect` delegates to `LocatorAssertions` and `PageAssertions`, sharing one auto-waiting and tracing implementation ### Fixed - `expect()->toHaveClass()` matches the class attribute exactly +- `expect()->toBeEmpty()` evaluates input values and text content in the DOM +- `expect()->not()` applies to one assertion instead of leaking to later assertions on the same object ## [1.3.1] - 2026-08-04 diff --git a/README.md b/README.md index d18e807..4ac57f1 100644 --- a/README.md +++ b/README.md @@ -180,7 +180,7 @@ Notes: - The trait provides `$this->playwright`, `$this->browser`, `$this->context`, and `$this->page` properties. - Call `setUpPlaywright()` in `setUp()` and `tearDownPlaywright()` in `tearDown()` for proper lifecycle management. -- Use `$this->expect($locator)` or `$this->expect($page)` for fluent assertions. +- Use `$this->expect($locator)` or `$this->expect($page)` for fluent assertions with auto-waiting. - If you prefer full control, you can skip the trait and use the static `Playwright` facade directly. ## CI usage (GitHub Actions) diff --git a/docs/guide/assertions-reference.md b/docs/guide/assertions-reference.md index c00eea9..7833f05 100644 --- a/docs/guide/assertions-reference.md +++ b/docs/guide/assertions-reference.md @@ -51,6 +51,11 @@ expect($this->page->locator('.success-message')) ->toBeVisible(); ``` +### `.withPollInterval()` + +Assertions poll every 100 milliseconds by default. Use `withPollInterval()` to +override that interval for the next assertion. + ----- ## Locator Assertions @@ -59,16 +64,21 @@ These assertions are available when you pass a `Locator` to `expect()`. * **`toBeVisible()`**: Asserts the locator resolves to a visible element. * **`toBeHidden()`**: Asserts the locator resolves to a hidden element. +* **`toBeAttached()`**: Asserts the locator resolves to an element in the DOM. * **`toBeEnabled()`**: Asserts the element is enabled. * **`toBeDisabled()`**: Asserts the element is disabled. * **`toBeChecked()`**: Asserts a checkbox or radio button is checked. +* **`toBeEmpty()`**: Asserts the element has no value or text content. * **`toBeFocused()`**: Asserts the element is focused. +* **`toHaveFocus()`**: Alias for `toBeFocused()`. * **`toHaveText(string $text)`**: Asserts the element contains the given text. * **`toHaveExactText(string $text)`**: Asserts the element's text is an exact match. * **`toContainText(string $text)`**: An alias for `toHaveText()`. * **`toHaveValue(string $value)`**: Asserts an input element has a specific value. * **`toHaveAttribute(string $name, string $value)`**: Asserts the element has the given attribute and value. * **`toHaveCSS(string $name, string $value)`**: Asserts the element has the given computed CSS style. +* **`toHaveId(string $id)`**: Asserts the element has the given ID. +* **`toHaveClass(string|array $class)`**: Asserts the complete class list matches. * **`toHaveCount(int $count)`**: Asserts the locator resolves to a specific number of elements. ----- diff --git a/src/Assertions/Internal/AbstractAssertions.php b/src/Assertions/Internal/AbstractAssertions.php new file mode 100644 index 0000000..87ff4a8 --- /dev/null +++ b/src/Assertions/Internal/AbstractAssertions.php @@ -0,0 +1,118 @@ +negated = !$this->negated; + } + + protected function setTimeout(int $timeoutMs): void + { + $this->timeoutMs = $timeoutMs; + } + + protected function setPollInterval(int $pollIntervalMs): void + { + $this->pollIntervalMs = $pollIntervalMs; + } + + /** + * @param callable(): bool $condition + * @param callable(): mixed|null $actualProvider + */ + protected function assertCondition( + callable $condition, + string $matcher, + ?AssertionOptions $options, + string $message, + string $negatedMessage, + mixed $expected = null, + ?callable $actualProvider = null, + ): void { + $negated = $this->negated; + $this->negated = false; + + if (null !== $this->tracing) { + $this->tracing->group(sprintf('expect(%s).%s%s', $this->subjectName(), $negated ? 'not.' : '', $matcher)); + } + + try { + $this->runAssertion($condition, !$negated, $options, $negated ? $negatedMessage : $message, $expected, $actualProvider); + } finally { + if (null !== $this->tracing) { + $this->tracing->groupEnd(); + } + } + } + + abstract protected function subjectName(): string; + + /** + * @param callable(): bool $condition + * @param callable(): mixed|null $actualProvider + */ + private function runAssertion( + callable $condition, + bool $expectedResult, + ?AssertionOptions $options, + string $message, + mixed $expected, + ?callable $actualProvider, + ): void { + $timeoutMs = null === $options || null === $options->timeoutMs ? $this->timeoutMs : $options->timeoutMs; + $pollIntervalMs = null === $options || null === $options->intervalMs ? $this->pollIntervalMs : $options->intervalMs; + $deadline = hrtime(true) + ($timeoutMs * 1_000_000); + + do { + try { + if ($condition() === $expectedResult) { + return; + } + } catch (\Throwable) { + } + + if (hrtime(true) < $deadline) { + usleep($pollIntervalMs * 1000); + } + } while (hrtime(true) < $deadline); + + $actual = null; + if (null !== $actualProvider) { + try { + $actual = $actualProvider(); + } catch (\Throwable) { + } + } + + $message = null === $options || null === $options->message ? $message : $options->message; + + throw new AssertionException($message, actual: $actual, expected: $expected); + } +} diff --git a/src/Assertions/LocatorAssertions.php b/src/Assertions/LocatorAssertions.php index 9b7bd6c..28710a2 100644 --- a/src/Assertions/LocatorAssertions.php +++ b/src/Assertions/LocatorAssertions.php @@ -14,12 +14,12 @@ namespace Playwright\Assertions; -use Playwright\Assertions\Failure\AssertionException; +use Playwright\Assertions\Internal\AbstractAssertions; use Playwright\Assertions\Internal\AriaSnapshot; -use Playwright\Assertions\Internal\Waiter; use Playwright\Locator\LocatorInterface; +use Playwright\Tracing\TracingInterface; -final class LocatorAssertions implements LocatorAssertionsInterface +final class LocatorAssertions extends AbstractAssertions implements LocatorAssertionsInterface { /** * Resolves one accessible text of the element and compares it with the @@ -83,15 +83,30 @@ final class LocatorAssertions implements LocatorAssertionsInterface } JS; - private bool $negated = false; + public function __construct( + private readonly LocatorInterface $locator, + ?TracingInterface $tracing = null, + ) { + parent::__construct($tracing); + } - public function __construct(private LocatorInterface $locator) + public function not(): self { + $this->negate(); + + return $this; } - public function not(): self + public function withTimeout(int $timeoutMs): self + { + $this->setTimeout($timeoutMs); + + return $this; + } + + public function withPollInterval(int $pollIntervalMs): self { - $this->negated = !$this->negated; + $this->setPollInterval($pollIntervalMs); return $this; } @@ -100,6 +115,7 @@ public function toBeAttached(?AssertionOptions $options = null): self { return $this->assertState( fn (): bool => $this->locator->isAttached(), + 'toBeAttached', $options, 'Expected locator to be attached.', 'Expected locator to be detached.', @@ -110,6 +126,7 @@ public function toBeEditable(?AssertionOptions $options = null): self { return $this->assertState( fn (): bool => $this->locator->isEditable(), + 'toBeEditable', $options, 'Expected locator to be editable.', 'Expected locator not to be editable.', @@ -143,6 +160,7 @@ public function toBeInViewport(?AssertionOptions $options = null): self return elementArea > 0 && visibleArea > 0 && visibleArea / elementArea >= requiredRatio; } JS, $ratio), + 'toBeInViewport', $options, 'Expected locator to be in the viewport.', 'Expected locator not to be in the viewport.', @@ -151,53 +169,24 @@ public function toBeInViewport(?AssertionOptions $options = null): self public function toBeVisible(?AssertionOptions $options = null): self { - $timeout = $options?->timeoutMs; - if (!is_int($timeout)) { - $timeout = Waiter::DEFAULT_TIMEOUT_MS; - } - $interval = $options?->intervalMs; - if (!is_int($interval)) { - $interval = 50; - } - - try { - Waiter::eventually(fn () => $this->locator->isVisible(), $timeout, $interval); - } catch (\Throwable $e) { - $ok = false; - if ($this->negated) { - $ok = true; - $this->negated = false; - } - if (!$ok) { - $msg = $options?->message; - if (null === $msg) { - $msg = 'Expected locator to be visible.'; - } - throw new AssertionException($msg); - } - - return $this; - } - - $ok = true; - if ($this->negated) { - $ok = false; - $this->negated = false; - } - if (!$ok) { - $msg = $options?->message; - if (null === $msg) { - $msg = 'Expected locator to be hidden.'; - } - throw new AssertionException($msg); - } - - return $this; + return $this->assertState( + fn (): bool => $this->locator->isVisible(), + 'toBeVisible', + $options, + 'Expected locator to be visible.', + 'Expected locator to be hidden.', + ); } public function toBeHidden(?AssertionOptions $options = null): self { - return $this->not()->toBeVisible($options); + return $this->assertState( + fn (): bool => !$this->locator->isVisible(), + 'toBeHidden', + $options, + 'Expected locator to be hidden.', + 'Expected locator to be visible.', + ); } /** @@ -205,18 +194,10 @@ public function toBeHidden(?AssertionOptions $options = null): self */ public function toHaveText(string|array $expected, ?AssertionOptions $options = null): self { - $timeout = $options?->timeoutMs; - if (!is_int($timeout)) { - $timeout = Waiter::DEFAULT_TIMEOUT_MS; - } $useInner = $options?->useInnerText; if (!is_bool($useInner)) { $useInner = false; } - $interval = $options?->intervalMs; - if (!is_int($interval)) { - $interval = 50; - } $predicate = function () use ($expected, $useInner) { $actual = \is_array($expected) @@ -226,54 +207,209 @@ public function toHaveText(string|array $expected, ?AssertionOptions $options = return $actual === $expected; }; - $ok = true; - try { - Waiter::eventually($predicate, $timeout, $interval); - } catch (\Throwable) { - $ok = false; - } + $this->assertCondition( + $predicate, + 'toHaveText', + $options, + 'Expected locator to have text.', + 'Expected locator not to have text.', + $expected, + fn (): mixed => $useInner ? $this->locator->innerText() : $this->locator->textContent(), + ); - if ($this->negated) { - $ok = !$ok; - $this->negated = false; - } + return $this; + } - if (!$ok) { - $msg = $options?->message; - if (null === $msg) { - $msg = 'Expected locator to have text.'; - } - throw new AssertionException($msg, actual: $useInner ? $this->locator->innerText() : $this->locator->textContent(), expected: $expected); - } + public function toContainText(string $expected, ?AssertionOptions $options = null): self + { + $this->assertCondition( + fn (): bool => str_contains($this->locator->textContent() ?? '', $expected), + 'toContainText', + $options, + sprintf('Expected locator text to contain %s.', json_encode($expected)), + sprintf('Expected locator text not to contain %s.', json_encode($expected)), + $expected, + fn (): ?string => $this->locator->textContent(), + ); return $this; } - public function toHaveCount(int $expected, ?AssertionOptions $options = null): self + public function toHaveExactText(string $expected, ?AssertionOptions $options = null): self { - $timeout = $options?->timeoutMs; - if (!is_int($timeout)) { - $timeout = Waiter::DEFAULT_TIMEOUT_MS; - } - $interval = $options?->intervalMs; - if (!is_int($interval)) { - $interval = 50; - } + $this->assertCondition( + fn (): bool => ($this->locator->textContent() ?? '') === $expected, + 'toHaveExactText', + $options, + 'Expected locator to have exact text.', + 'Expected locator not to have exact text.', + $expected, + fn (): ?string => $this->locator->textContent(), + ); - $ok = true; - try { - Waiter::eventually(fn () => $this->locator->count() === $expected, $timeout, $interval); - } catch (\Throwable) { - $ok = false; - } + return $this; + } - if ($this->negated) { - $ok = !$ok; - $this->negated = false; - } - if (!$ok) { - throw new AssertionException('Expected locator count to match.', actual: $this->locator->count(), expected: $expected); - } + public function toHaveValue(string $expected, ?AssertionOptions $options = null): self + { + $this->assertCondition( + fn (): bool => $this->locator->inputValue() === $expected, + 'toHaveValue', + $options, + 'Expected locator to have value.', + 'Expected locator not to have value.', + $expected, + fn (): string => $this->locator->inputValue(), + ); + + return $this; + } + + public function toHaveAttribute(string $name, string $expected, ?AssertionOptions $options = null): self + { + $this->assertCondition( + fn (): bool => $this->locator->getAttribute($name) === $expected, + 'toHaveAttribute', + $options, + sprintf('Expected locator attribute "%s" to match.', $name), + sprintf('Expected locator attribute "%s" not to match.', $name), + $expected, + fn (): ?string => $this->locator->getAttribute($name), + ); + + return $this; + } + + public function toBeChecked(?AssertionOptions $options = null): self + { + return $this->assertState( + fn (): bool => $this->locator->isChecked(), + 'toBeChecked', + $options, + 'Expected locator to be checked.', + 'Expected locator not to be checked.', + ); + } + + public function toBeEnabled(?AssertionOptions $options = null): self + { + return $this->assertState( + fn (): bool => $this->locator->isEnabled(), + 'toBeEnabled', + $options, + 'Expected locator to be enabled.', + 'Expected locator not to be enabled.', + ); + } + + public function toBeDisabled(?AssertionOptions $options = null): self + { + return $this->assertState( + fn (): bool => !$this->locator->isEnabled(), + 'toBeDisabled', + $options, + 'Expected locator to be disabled.', + 'Expected locator not to be disabled.', + ); + } + + public function toHaveCSS(string $name, string $expected, ?AssertionOptions $options = null): self + { + $actual = fn (): mixed => $this->locator->evaluate( + '(element, property) => window.getComputedStyle(element).getPropertyValue(property)', + $name, + ); + + $this->assertCondition( + fn (): bool => $actual() === $expected, + 'toHaveCSS', + $options, + sprintf('Expected locator CSS property "%s" to match.', $name), + sprintf('Expected locator CSS property "%s" not to match.', $name), + $expected, + $actual, + ); + + return $this; + } + + public function toHaveId(string $expected, ?AssertionOptions $options = null): self + { + return $this->toHaveAttribute('id', $expected, $options); + } + + /** + * @param string|string[] $expected + */ + public function toHaveClass(string|array $expected, ?AssertionOptions $options = null): self + { + $expectedClasses = self::classTokens(is_array($expected) ? implode(' ', $expected) : $expected); + + $this->assertCondition( + function () use ($expectedClasses): bool { + $actual = $this->locator->getAttribute('class'); + + return null !== $actual && self::classTokens($actual) === $expectedClasses; + }, + 'toHaveClass', + $options, + 'Expected locator class list to match.', + 'Expected locator class list not to match.', + implode(' ', $expectedClasses), + fn (): ?string => $this->locator->getAttribute('class'), + ); + + return $this; + } + + public function toBeEmpty(?AssertionOptions $options = null): self + { + return $this->assertState( + fn (): bool => true === $this->locator->evaluate(<<<'JS' + (element) => 'value' in element + ? element.value === '' + : (element.textContent ?? '') === '' + JS), + 'toBeEmpty', + $options, + 'Expected locator to be empty.', + 'Expected locator not to be empty.', + ); + } + + public function toBeFocused(?AssertionOptions $options = null): self + { + return $this->assertState( + fn (): bool => true === $this->locator->evaluate('(element) => document.activeElement === element'), + 'toBeFocused', + $options, + 'Expected locator to be focused.', + 'Expected locator not to be focused.', + ); + } + + public function toHaveFocus(?AssertionOptions $options = null): self + { + return $this->assertState( + fn (): bool => true === $this->locator->evaluate('(element) => document.activeElement === element'), + 'toHaveFocus', + $options, + 'Expected locator to have focus.', + 'Expected locator not to have focus.', + ); + } + + public function toHaveCount(int $expected, ?AssertionOptions $options = null): self + { + $this->assertCondition( + fn (): bool => $this->locator->count() === $expected, + 'toHaveCount', + $options, + 'Expected locator count to match.', + 'Expected locator count not to match.', + $expected, + fn (): int => $this->locator->count(), + ); return $this; } @@ -302,6 +438,7 @@ public function toHaveJSProperty(string $name, mixed $expected, ?AssertionOption return equal(element[payload.name], payload.expected); } JS, ['name' => $name, 'expected' => $expected]), + 'toHaveJSProperty', $options, sprintf('Expected locator JavaScript property "%s" to match.', $name), sprintf('Expected locator JavaScript property "%s" not to match.', $name), @@ -321,6 +458,7 @@ public function toHaveValues(array|string $expected, ?AssertionOptions $options ? Array.from(element.selectedOptions, option => option.value) : null JS) === $expected, + 'toHaveValues', $options, 'Expected locator to have selected values.', 'Expected locator not to have selected values.', @@ -379,6 +517,7 @@ public function toHaveRole(string $role, ?AssertionOptions $options = null): sel } } JS) === $role, + 'toHaveRole', $options, sprintf('Expected locator to have role "%s".', $role), sprintf('Expected locator not to have role "%s".', $role), @@ -397,6 +536,7 @@ public function toContainClass(string $expected, ?AssertionOptions $options = nu '(element, expectedClasses) => expectedClasses.every(className => element.classList.contains(className))', $classes ), + 'toContainClass', $options, sprintf('Expected locator to contain class "%s".', $expected), sprintf('Expected locator not to contain class "%s".', $expected), @@ -406,6 +546,7 @@ public function toContainClass(string $expected, ?AssertionOptions $options = nu public function toHaveAccessibleName(string $expected, ?AssertionOptions $options = null): self { return $this->assertAccessibleText( + 'toHaveAccessibleName', 'name', $expected, $options, @@ -417,6 +558,7 @@ public function toHaveAccessibleName(string $expected, ?AssertionOptions $option public function toHaveAccessibleDescription(string $expected, ?AssertionOptions $options = null): self { return $this->assertAccessibleText( + 'toHaveAccessibleDescription', 'description', $expected, $options, @@ -428,6 +570,7 @@ public function toHaveAccessibleDescription(string $expected, ?AssertionOptions public function toHaveAccessibleErrorMessage(string $expected, ?AssertionOptions $options = null): self { return $this->assertAccessibleText( + 'toHaveAccessibleErrorMessage', 'errorMessage', $expected, $options, @@ -442,13 +585,14 @@ public function toMatchAriaSnapshot(string $expected, ?AssertionOptions $options return $this->assertState( fn (): bool => AriaSnapshot::normalize($this->locator->ariaSnapshot()) === $normalized, + 'toMatchAriaSnapshot', $options, 'Expected locator to match the ARIA snapshot.', 'Expected locator not to match the ARIA snapshot.', ); } - private function assertAccessibleText(string $kind, string $expected, ?AssertionOptions $options, string $expectedMessage, string $negatedMessage): self + private function assertAccessibleText(string $matcher, string $kind, string $expected, ?AssertionOptions $options, string $expectedMessage, string $negatedMessage): self { $payload = [ 'kind' => $kind, @@ -458,6 +602,7 @@ private function assertAccessibleText(string $kind, string $expected, ?Assertion return $this->assertState( fn (): bool => true === $this->locator->evaluate(self::ACCESSIBLE_TEXT_JS, $payload), + $matcher, $options, $expectedMessage, $negatedMessage, @@ -465,36 +610,28 @@ private function assertAccessibleText(string $kind, string $expected, ?Assertion } /** + * @param callable(): bool $predicate * @param callable(): bool $predicate */ - private function assertState(callable $predicate, ?AssertionOptions $options, string $expectedMessage, string $negatedMessage): self + private function assertState(callable $predicate, string $matcher, ?AssertionOptions $options, string $expectedMessage, string $negatedMessage): self { - $timeout = $options?->timeoutMs; - if (!is_int($timeout)) { - $timeout = Waiter::DEFAULT_TIMEOUT_MS; - } - $interval = $options?->intervalMs; - if (!is_int($interval)) { - $interval = 50; - } + $this->assertCondition($predicate, $matcher, $options, $expectedMessage, $negatedMessage); - $ok = true; - try { - Waiter::eventually($predicate, $timeout, $interval); - } catch (\Throwable) { - $ok = false; - } + return $this; + } - $wasNegated = $this->negated; - if ($wasNegated) { - $ok = !$ok; - $this->negated = false; - } - if (!$ok) { - $message = $options instanceof AssertionOptions ? $options->message : null; - throw new AssertionException($message ?? ($wasNegated ? $negatedMessage : $expectedMessage)); - } + /** + * @return list + */ + private static function classTokens(string $classAttribute): array + { + $tokens = preg_split('/\s+/', trim($classAttribute), -1, PREG_SPLIT_NO_EMPTY); - return $this; + return false === $tokens ? [] : $tokens; + } + + protected function subjectName(): string + { + return $this->locator->getSelector(); } } diff --git a/src/Assertions/LocatorAssertionsInterface.php b/src/Assertions/LocatorAssertionsInterface.php index 8137e29..69de067 100644 --- a/src/Assertions/LocatorAssertionsInterface.php +++ b/src/Assertions/LocatorAssertionsInterface.php @@ -40,6 +40,18 @@ public function toBeVisible(?AssertionOptions $options = null): self; public function toBeHidden(?AssertionOptions $options = null): self; + public function toBeChecked(?AssertionOptions $options = null): self; + + public function toBeEnabled(?AssertionOptions $options = null): self; + + public function toBeDisabled(?AssertionOptions $options = null): self; + + public function toBeEmpty(?AssertionOptions $options = null): self; + + public function toBeFocused(?AssertionOptions $options = null): self; + + public function toHaveFocus(?AssertionOptions $options = null): self; + /** * @param string|array $expected * @@ -47,6 +59,23 @@ public function toBeHidden(?AssertionOptions $options = null): self; */ public function toHaveText(string|array $expected, ?AssertionOptions $options = null): self; + public function toContainText(string $expected, ?AssertionOptions $options = null): self; + + public function toHaveExactText(string $expected, ?AssertionOptions $options = null): self; + + public function toHaveValue(string $expected, ?AssertionOptions $options = null): self; + + public function toHaveAttribute(string $name, string $expected, ?AssertionOptions $options = null): self; + + public function toHaveCSS(string $name, string $expected, ?AssertionOptions $options = null): self; + + public function toHaveId(string $expected, ?AssertionOptions $options = null): self; + + /** + * @param string|string[] $expected + */ + public function toHaveClass(string|array $expected, ?AssertionOptions $options = null): self; + public function toHaveCount(int $expected, ?AssertionOptions $options = null): self; /** @@ -119,4 +148,8 @@ public function toHaveAccessibleErrorMessage(string $expected, ?AssertionOptions public function toMatchAriaSnapshot(string $expected, ?AssertionOptions $options = null): self; public function not(): self; + + public function withTimeout(int $timeoutMs): self; + + public function withPollInterval(int $pollIntervalMs): self; } diff --git a/src/Assertions/PageAssertions.php b/src/Assertions/PageAssertions.php index c4252fc..a4ce047 100644 --- a/src/Assertions/PageAssertions.php +++ b/src/Assertions/PageAssertions.php @@ -14,22 +14,37 @@ namespace Playwright\Assertions; -use Playwright\Assertions\Failure\AssertionException; +use Playwright\Assertions\Internal\AbstractAssertions; use Playwright\Assertions\Internal\AriaSnapshot; -use Playwright\Assertions\Internal\Waiter; use Playwright\Page\PageInterface; +use Playwright\Tracing\TracingInterface; -final class PageAssertions implements PageAssertionsInterface +final class PageAssertions extends AbstractAssertions implements PageAssertionsInterface { - private bool $negated = false; + public function __construct( + private readonly PageInterface $page, + ?TracingInterface $tracing = null, + ) { + parent::__construct($tracing); + } - public function __construct(private PageInterface $page) + public function not(): self { + $this->negate(); + + return $this; } - public function not(): self + public function withTimeout(int $timeoutMs): self + { + $this->setTimeout($timeoutMs); + + return $this; + } + + public function withPollInterval(int $pollIntervalMs): self { - $this->negated = !$this->negated; + $this->setPollInterval($pollIntervalMs); return $this; } @@ -37,29 +52,15 @@ public function not(): self public function toHaveTitle(string|\Stringable $expected, ?AssertionOptions $options = null): self { $expected = (string) $expected; - $timeout = $options?->timeoutMs; - if (!is_int($timeout)) { - $timeout = Waiter::DEFAULT_TIMEOUT_MS; - } - $interval = $options?->intervalMs; - if (!is_int($interval)) { - $interval = 50; - } - - $ok = true; - try { - Waiter::eventually(fn () => $this->page->title() === $expected, $timeout, $interval); - } catch (\Throwable) { - $ok = false; - } - - if ($this->negated) { - $ok = !$ok; - $this->negated = false; - } - if (!$ok) { - throw new AssertionException('Expected page title to match.', actual: $this->page->title(), expected: $expected); - } + $this->assertCondition( + fn (): bool => $this->page->title() === $expected, + 'toHaveTitle', + $options, + 'Expected page title to match.', + 'Expected page title not to match.', + $expected, + fn (): string => $this->page->title(), + ); return $this; } @@ -67,29 +68,15 @@ public function toHaveTitle(string|\Stringable $expected, ?AssertionOptions $opt public function toHaveURL(string|\Stringable $expected, ?AssertionOptions $options = null): self { $expected = (string) $expected; - $timeout = $options?->timeoutMs; - if (!is_int($timeout)) { - $timeout = Waiter::DEFAULT_TIMEOUT_MS; - } - $interval = $options?->intervalMs; - if (!is_int($interval)) { - $interval = 50; - } - - $ok = true; - try { - Waiter::eventually(fn () => $this->page->url() === $expected, $timeout, $interval); - } catch (\Throwable) { - $ok = false; - } - - if ($this->negated) { - $ok = !$ok; - $this->negated = false; - } - if (!$ok) { - throw new AssertionException('Expected page URL to match.', actual: $this->page->url(), expected: $expected); - } + $this->assertCondition( + fn (): bool => $this->page->url() === $expected, + 'toHaveURL', + $options, + 'Expected page URL to match.', + 'Expected page URL not to match.', + $expected, + fn (): string => $this->page->url(), + ); return $this; } @@ -98,8 +85,9 @@ public function toMatchAriaSnapshot(string $expected, ?AssertionOptions $options { $normalized = AriaSnapshot::normalize($expected); - $this->assertState( + $this->assertCondition( fn (): bool => AriaSnapshot::normalize($this->page->locator('body')->ariaSnapshot()) === $normalized, + 'toMatchAriaSnapshot', $options, 'Expected page to match the ARIA snapshot.', 'Expected page not to match the ARIA snapshot.', @@ -108,35 +96,8 @@ public function toMatchAriaSnapshot(string $expected, ?AssertionOptions $options return $this; } - /** - * @param callable(): bool $predicate - */ - private function assertState(callable $predicate, ?AssertionOptions $options, string $expectedMessage, string $negatedMessage): void + protected function subjectName(): string { - $timeout = $options?->timeoutMs; - if (!is_int($timeout)) { - $timeout = Waiter::DEFAULT_TIMEOUT_MS; - } - $interval = $options?->intervalMs; - if (!is_int($interval)) { - $interval = 50; - } - - $ok = true; - try { - Waiter::eventually($predicate, $timeout, $interval); - } catch (\Throwable) { - $ok = false; - } - - $wasNegated = $this->negated; - if ($wasNegated) { - $ok = !$ok; - $this->negated = false; - } - if (!$ok) { - $message = $options instanceof AssertionOptions ? $options->message : null; - throw new AssertionException($message ?? ($wasNegated ? $negatedMessage : $expectedMessage)); - } + return 'page'; } } diff --git a/src/Assertions/PageAssertionsInterface.php b/src/Assertions/PageAssertionsInterface.php index 48a753c..5f534cb 100644 --- a/src/Assertions/PageAssertionsInterface.php +++ b/src/Assertions/PageAssertionsInterface.php @@ -35,4 +35,8 @@ public function toMatchAriaSnapshot(string $expected, ?AssertionOptions $options /** @return $this */ public function not(): self; + + public function withTimeout(int $timeoutMs): self; + + public function withPollInterval(int $pollIntervalMs): self; } diff --git a/src/Testing/Expect.php b/src/Testing/Expect.php index 7b1bf25..8e2279e 100644 --- a/src/Testing/Expect.php +++ b/src/Testing/Expect.php @@ -14,471 +14,165 @@ namespace Playwright\Testing; -use Playwright\Assertions\Failure\AssertionException; +use Playwright\Assertions\LocatorAssertions; +use Playwright\Assertions\LocatorAssertionsInterface; +use Playwright\Assertions\PageAssertions; +use Playwright\Assertions\PageAssertionsInterface; use Playwright\Locator\LocatorInterface; use Playwright\Page\PageInterface; use Playwright\Tracing\TracingInterface; final class Expect implements ExpectInterface { - private bool $negated = false; + private readonly LocatorAssertionsInterface|PageAssertionsInterface $assertions; - private int $timeoutMs = 5000; - - private int $pollIntervalMs = 100; - - public function __construct( - private readonly LocatorInterface|PageInterface $subject, - private readonly ?TracingInterface $tracing = null, - ) { + public function __construct(LocatorInterface|PageInterface $subject, ?TracingInterface $tracing = null) + { + $this->assertions = $subject instanceof LocatorInterface + ? new LocatorAssertions($subject, $tracing) + : new PageAssertions($subject, $tracing); + $this->assertions->withPollInterval(100); } public function not(): self { - $this->negated = !$this->negated; + $this->assertions->not(); return $this; } public function withTimeout(int $timeoutMs): self { - $this->timeoutMs = $timeoutMs; + $this->assertions->withTimeout($timeoutMs); return $this; } public function withPollInterval(int $pollIntervalMs): self { - $this->pollIntervalMs = $pollIntervalMs; + $this->assertions->withPollInterval($pollIntervalMs); return $this; } - public function toBeVisible(): void + public function toBeAttached(): void { - if (!$this->subject instanceof LocatorInterface) { - throw new \InvalidArgumentException('toBeVisible() can only be used with LocatorInterface'); - } - - $this->retryAssertion( - fn () => $this->subject->isVisible(), - !$this->negated, - $this->negated ? 'Locator is visible, but expected not to be.' : 'Locator is not visible.' - ); + $this->locator(__FUNCTION__)->toBeAttached(); } - public function toBeEmpty(): void + public function toBeVisible(): void { - if (!$this->subject instanceof LocatorInterface) { - throw new \InvalidArgumentException('toBeEmpty() can only be used with LocatorInterface'); - } + $this->locator(__FUNCTION__)->toBeVisible(); + } - $this->retryAssertion( - fn () => $this->subject->isEmpty(), - !$this->negated, - $this->negated ? 'Locator is empty, but expected not to be.' : 'Locator is not empty.' - ); + public function toBeHidden(): void + { + $this->locator(__FUNCTION__)->toBeHidden(); } public function toHaveText(string $text): void { - if (!$this->subject instanceof LocatorInterface) { - throw new \InvalidArgumentException('toHaveText() can only be used with LocatorInterface'); - } - - $this->retryAssertion( - fn () => \str_contains($this->subject->textContent() ?? '', $text), - !$this->negated, - $this->negated - ? \sprintf('Locator text contains "%s", but expected not to.', $text) - : \sprintf('Locator text does not contain "%s".', $text), - function () use ($text): string { - \assert($this->subject instanceof LocatorInterface); - $actual = (string) ($this->subject->textContent() ?? ''); - - return $this->negated - ? \sprintf('Expected text not to contain %s. Actual: %s', \json_encode($text), \json_encode($actual)) - : \sprintf('Expected text to contain %s, but was %s', \json_encode($text), \json_encode($actual)); - } - ); + $this->locator(__FUNCTION__)->toContainText($text); } public function toContainText(string $text): void { - $this->toHaveText($text); + $this->locator(__FUNCTION__)->toContainText($text); } public function toHaveExactText(string $text): void { - if (!$this->subject instanceof LocatorInterface) { - throw new \InvalidArgumentException('toHaveExactText() can only be used with LocatorInterface'); - } - - $this->retryAssertion( - fn () => (string) ($this->subject->textContent() ?? '') === $text, - !$this->negated, - $this->negated - ? \sprintf('Locator text is exactly "%s", but expected not to be.', $text) - : \sprintf('Locator text is not exactly "%s".', $text), - function () use ($text): string { - \assert($this->subject instanceof LocatorInterface); - $actual = (string) ($this->subject->textContent() ?? ''); - - return $this->negated - ? \sprintf('Expected exact text not %s. Actual: %s', \json_encode($text), \json_encode($actual)) - : \sprintf('Expected exact text %s, but was %s', \json_encode($text), \json_encode($actual)); - } - ); + $this->locator(__FUNCTION__)->toHaveExactText($text); } public function toHaveValue(string $value): void { - if (!$this->subject instanceof LocatorInterface) { - throw new \InvalidArgumentException('toHaveValue() can only be used with LocatorInterface'); - } - - $this->retryAssertion( - fn () => $this->subject->inputValue() === $value, - !$this->negated, - $this->negated - ? \sprintf('Locator value is "%s", but expected not to be.', $value) - : \sprintf('Locator value is not "%s".', $value), - function () use ($value): string { - \assert($this->subject instanceof LocatorInterface); - $actual = (string) $this->subject->inputValue(); - - return $this->negated - ? \sprintf('Expected value not to be %s. Actual: %s', \json_encode($value), \json_encode($actual)) - : \sprintf('Expected value %s, but was %s', \json_encode($value), \json_encode($actual)); - } - ); + $this->locator(__FUNCTION__)->toHaveValue($value); } public function toHaveAttribute(string $name, string $value): void { - if (!$this->subject instanceof LocatorInterface) { - throw new \InvalidArgumentException('toHaveAttribute() can only be used with LocatorInterface'); - } - - $this->retryAssertion( - fn () => $this->subject->getAttribute($name) === $value, - !$this->negated, - $this->negated - ? \sprintf('Locator attribute "%s" is "%s", but expected not to be.', $name, $value) - : \sprintf('Locator attribute "%s" is not "%s".', $name, $value), - function () use ($name, $value): string { - \assert($this->subject instanceof LocatorInterface); - $actual = $this->subject->getAttribute($name); - - return $this->negated - ? \sprintf('Expected attribute %s not to be %s. Actual: %s', \json_encode($name), \json_encode($value), \json_encode($actual)) - : \sprintf('Expected attribute %s = %s, but was %s', \json_encode($name), \json_encode($value), \json_encode($actual)); - } - ); + $this->locator(__FUNCTION__)->toHaveAttribute($name, $value); } public function toBeChecked(): void { - if (!$this->subject instanceof LocatorInterface) { - throw new \InvalidArgumentException('toBeChecked() can only be used with LocatorInterface'); - } - - $this->retryAssertion( - fn () => $this->subject->isChecked(), - !$this->negated, - $this->negated ? 'Locator is checked, but expected not to be.' : 'Locator is not checked.' - ); + $this->locator(__FUNCTION__)->toBeChecked(); } public function toBeEnabled(): void { - if (!$this->subject instanceof LocatorInterface) { - throw new \InvalidArgumentException('toBeEnabled() can only be used with LocatorInterface'); - } - - $this->retryAssertion( - fn () => $this->subject->isEnabled(), - !$this->negated, - $this->negated ? 'Locator is enabled, but expected not to be.' : 'Locator is not enabled.' - ); - } - - public function toBeHidden(): void - { - if (!$this->subject instanceof LocatorInterface) { - throw new \InvalidArgumentException('toBeHidden() can only be used with LocatorInterface'); - } - - $this->retryAssertion( - fn () => !$this->subject->isVisible(), - !$this->negated, - $this->negated ? 'Locator is hidden, but expected not to be.' : 'Locator is not hidden.' - ); + $this->locator(__FUNCTION__)->toBeEnabled(); } public function toBeDisabled(): void { - if (!$this->subject instanceof LocatorInterface) { - throw new \InvalidArgumentException('toBeDisabled() can only be used with LocatorInterface'); - } - - $this->retryAssertion( - fn () => !$this->subject->isEnabled(), - !$this->negated, - $this->negated ? 'Locator is disabled, but expected not to be.' : 'Locator is not disabled.' - ); - } - - public function toHaveCount(int $count): void - { - if (!$this->subject instanceof LocatorInterface) { - throw new \InvalidArgumentException('toHaveCount() can only be used with LocatorInterface'); - } - - $this->retryAssertion( - fn () => $this->subject->count() === $count, - !$this->negated, - $this->negated - ? \sprintf('Locator count is %d, but expected not to be.', $count) - : \sprintf('Locator count is not %d.', $count), - function () use ($count): string { - \assert($this->subject instanceof LocatorInterface); - $actual = $this->subject->count(); - - return $this->negated - ? \sprintf('Expected count not %d. Actual: %d', $count, $actual) - : \sprintf('Expected count %d, but was %d', $count, $actual); - } - ); + $this->locator(__FUNCTION__)->toBeDisabled(); } - public function toBeFocused(): void + public function toHaveCSS(string $name, string $value): void { - if (!$this->subject instanceof LocatorInterface) { - throw new \InvalidArgumentException('toBeFocused() can only be used with LocatorInterface'); - } - - $this->retryAssertion( - fn () => (bool) $this->subject->evaluate('(element) => document.activeElement === element'), - !$this->negated, - $this->negated ? 'Locator is focused, but expected not to be.' : 'Locator is not focused.' - ); + $this->locator(__FUNCTION__)->toHaveCSS($name, $value); } - public function toHaveFocus(): void + public function toHaveId(string $id): void { - if (!$this->subject instanceof LocatorInterface) { - throw new \InvalidArgumentException('toHaveFocus() can only be used with LocatorInterface'); - } - - $this->retryAssertion( - fn () => (bool) $this->subject->evaluate('(element) => document.activeElement === element'), - !$this->negated, - $this->negated ? 'Locator has focus, but expected not to have.' : 'Locator has not focus.' - ); + $this->locator(__FUNCTION__)->toHaveId($id); } - public function toHaveTitle(string $title): void + /** + * @param string|string[] $class + */ + public function toHaveClass(string|array $class): void { - if (!$this->subject instanceof PageInterface) { - throw new \InvalidArgumentException('toHaveTitle() can only be used with PageInterface'); - } - - $this->retryAssertion( - fn () => $this->subject->title() === $title, - !$this->negated, - $this->negated - ? \sprintf('Page title is "%s", but expected not to be.', $title) - : \sprintf('Page title is not "%s".', $title), - function () use ($title): string { - \assert($this->subject instanceof PageInterface); - $actual = $this->subject->title(); - - return $this->negated - ? \sprintf('Expected title not %s. Actual: %s', \json_encode($title), \json_encode($actual)) - : \sprintf('Expected title %s, but was %s', \json_encode($title), \json_encode($actual)); - } - ); + $this->locator(__FUNCTION__)->toHaveClass($class); } - public function toHaveURL(string $url): void + public function toBeEmpty(): void { - if (!$this->subject instanceof PageInterface) { - throw new \InvalidArgumentException('toHaveURL() can only be used with PageInterface'); - } - - $this->retryAssertion( - fn () => $this->subject->url() === $url, - !$this->negated, - $this->negated - ? \sprintf('Page URL is "%s", but expected not to be.', $url) - : \sprintf('Page URL is not "%s".', $url), - function () use ($url): string { - \assert($this->subject instanceof PageInterface); - $actual = $this->subject->url(); - - return $this->negated - ? \sprintf('Expected URL not %s. Actual: %s', \json_encode($url), \json_encode($actual)) - : \sprintf('Expected URL %s, but was %s', \json_encode($url), \json_encode($actual)); - } - ); + $this->locator(__FUNCTION__)->toBeEmpty(); } - public function toHaveClass(string|array $class): void + public function toHaveCount(int $count): void { - if (!$this->subject instanceof LocatorInterface) { - throw new \InvalidArgumentException('toHaveClass() can only be used with LocatorInterface'); - } - - $expectedClasses = self::classTokens(is_array($class) ? implode(' ', $class) : $class); - - $this->retryAssertion( - function () use ($expectedClasses) { - \assert($this->subject instanceof LocatorInterface); - $elementClass = $this->subject->getAttribute('class'); - if (null === $elementClass) { - return false; - } - - return self::classTokens($elementClass) === $expectedClasses; - }, - !$this->negated, - $this->negated - ? 'Locator has the specified class(es), but expected not to.' - : 'Locator does not have the specified class(es).', - function () use ($expectedClasses): string { - \assert($this->subject instanceof LocatorInterface); - $actual = (string) $this->subject->getAttribute('class'); - $expected = implode(' ', $expectedClasses); - - return $this->negated - ? \sprintf('Expected class list not to be %s. Actual: %s', \json_encode($expected), \json_encode($actual)) - : \sprintf('Expected class list to be %s. Actual: %s', \json_encode($expected), \json_encode($actual)); - } - ); + $this->locator(__FUNCTION__)->toHaveCount($count); } - /** - * @return array - */ - private static function classTokens(string $classAttribute): array + public function toBeFocused(): void { - $tokens = preg_split('/\s+/', trim($classAttribute), -1, PREG_SPLIT_NO_EMPTY); - - return false === $tokens ? [] : $tokens; + $this->locator(__FUNCTION__)->toBeFocused(); } - public function toHaveId(string $id): void + public function toHaveFocus(): void { - $this->toHaveAttribute('id', $id); + $this->locator(__FUNCTION__)->toHaveFocus(); } - /** - * Core retry assertion mechanism with configurable timeout and polling. - */ - private function retryAssertion(callable $condition, bool $expectedResult, string $message, ?callable $failureMessageProvider = null): void + public function toHaveTitle(string $title): void { - if (null === $this->tracing) { - $this->runAssertion($condition, $expectedResult, $message, $failureMessageProvider); - - return; - } - - $this->tracing->group($this->traceGroupName()); - - try { - $this->runAssertion($condition, $expectedResult, $message, $failureMessageProvider); - } finally { - $this->tracing->groupEnd(); - } + $this->page(__FUNCTION__)->toHaveTitle($title); } - private function traceGroupName(): string + public function toHaveURL(string $url): void { - $matcher = 'assertion'; - foreach (\debug_backtrace(\DEBUG_BACKTRACE_IGNORE_ARGS, 4) as $frame) { - if (self::class === ($frame['class'] ?? null) && \str_starts_with($frame['function'], 'to')) { - $matcher = $frame['function']; - break; - } - } - - $subject = $this->subject instanceof LocatorInterface ? $this->subject->getSelector() : 'page'; - - return \sprintf('expect(%s).%s%s', $subject, $this->negated ? 'not.' : '', $matcher); + $this->page(__FUNCTION__)->toHaveURL($url); } - private function runAssertion(callable $condition, bool $expectedResult, string $message, ?callable $failureMessageProvider = null): void + private function locator(string $matcher): LocatorAssertionsInterface { - $startTime = \microtime(true); - $endTime = $startTime + ($this->timeoutMs / 1000); - - $lastException = null; - - while (\microtime(true) < $endTime) { - try { - $actualResult = $condition(); - - if ($actualResult === $expectedResult) { - return; - } - - $lastException = new AssertionException($message); - } catch (\Throwable $e) { - $lastException = $e; - } - - \usleep($this->pollIntervalMs * 1000); - } - - $finalMessage = $message; - if (null !== $failureMessageProvider) { - try { - $computed = $failureMessageProvider(); - if (\is_string($computed) && '' !== $computed) { - $finalMessage = $computed; - } - } catch (\Throwable) { - } + if (!$this->assertions instanceof LocatorAssertionsInterface) { + throw new \InvalidArgumentException(sprintf('%s() can only be used with LocatorInterface', $matcher)); } - if ($lastException instanceof AssertionException) { - throw $lastException; - } - - if ($lastException) { - throw new AssertionException(\sprintf('Assertion timed out after %dms: %s. Last error: %s', $this->timeoutMs, $finalMessage, $lastException->getMessage())); - } - - throw new AssertionException(\sprintf('Assertion timed out after %dms: %s', $this->timeoutMs, $finalMessage)); + return $this->assertions; } - public function toHaveCSS(string $name, string $value): void + private function page(string $matcher): PageAssertionsInterface { - if (!$this->subject instanceof LocatorInterface) { - throw new \InvalidArgumentException('toHaveCSS() can only be used with LocatorInterface'); + if (!$this->assertions instanceof PageAssertionsInterface) { + throw new \InvalidArgumentException(sprintf('%s() can only be used with PageInterface', $matcher)); } - $this->retryAssertion( - fn () => $this->subject->evaluate(\sprintf('(element) => window.getComputedStyle(element).%s', $name)) === $value, - !$this->negated, - $this->negated - ? \sprintf('Locator CSS property "%s" is "%s", but expected not to be.', $name, $value) - : \sprintf('Locator CSS property "%s" is not "%s".', $name, $value), - function () use ($name, $value): string { - \assert($this->subject instanceof LocatorInterface); - $evaluated = $this->subject->evaluate(\sprintf('(element) => window.getComputedStyle(element).%s', $name)); - $actual = match (true) { - \is_string($evaluated) => $evaluated, - \is_scalar($evaluated) => (string) $evaluated, - \is_null($evaluated) => 'null', - default => 'non-scalar', - }; - - return $this->negated - ? \sprintf('Expected CSS %s not %s. Actual: %s', \json_encode($name), \json_encode($value), \json_encode($actual)) - : \sprintf('Expected CSS %s = %s, but was %s', \json_encode($name), \json_encode($value), \json_encode($actual)); - } - ); + return $this->assertions; } } diff --git a/src/Testing/ExpectDecorator.php b/src/Testing/ExpectDecorator.php index 87789a8..5230ed3 100644 --- a/src/Testing/ExpectDecorator.php +++ b/src/Testing/ExpectDecorator.php @@ -29,6 +29,12 @@ public function __construct( ) { } + public function toBeAttached(): void + { + $this->expect->toBeAttached(); + $this->recordAssertion(1); + } + public function toBeVisible(): void { $this->expect->toBeVisible(); diff --git a/src/Testing/ExpectInterface.php b/src/Testing/ExpectInterface.php index 3dccc47..98bcdff 100644 --- a/src/Testing/ExpectInterface.php +++ b/src/Testing/ExpectInterface.php @@ -16,6 +16,8 @@ interface ExpectInterface { + public function toBeAttached(): void; + public function toBeVisible(): void; public function toBeHidden(): void; diff --git a/tests/Functional/Tracing/ExpectTraceGroupsTest.php b/tests/Functional/Tracing/ExpectTraceGroupsTest.php index e6191f2..307e69b 100644 --- a/tests/Functional/Tracing/ExpectTraceGroupsTest.php +++ b/tests/Functional/Tracing/ExpectTraceGroupsTest.php @@ -15,11 +15,17 @@ namespace Playwright\Tests\Functional\Tracing; use PHPUnit\Framework\Attributes\CoversClass; +use Playwright\Assertions\LocatorAssertions; +use Playwright\Assertions\PageAssertions; use Playwright\Testing\Expect; +use Playwright\Testing\ExpectDecorator; use Playwright\Tests\Functional\FunctionalTestCase; use Playwright\Tracing\Tracing; #[CoversClass(Expect::class)] +#[CoversClass(ExpectDecorator::class)] +#[CoversClass(LocatorAssertions::class)] +#[CoversClass(PageAssertions::class)] #[CoversClass(Tracing::class)] final class ExpectTraceGroupsTest extends FunctionalTestCase { diff --git a/tests/Integration/Assertions/LocatorAssertionsTest.php b/tests/Integration/Assertions/LocatorAssertionsTest.php index 73bf124..5e1c0a6 100644 --- a/tests/Integration/Assertions/LocatorAssertionsTest.php +++ b/tests/Integration/Assertions/LocatorAssertionsTest.php @@ -82,6 +82,7 @@ public function itAssertsAttachedAndEditableLocators(): void Expect::locator($attached)->toBeAttached(); Expect::locator($editable)->toBeEditable(); + $this->expect($attached)->toBeAttached(); } #[Test] diff --git a/tests/Integration/Testing/ExpectTest.php b/tests/Integration/Testing/ExpectTest.php index c6a3ed1..6707d91 100644 --- a/tests/Integration/Testing/ExpectTest.php +++ b/tests/Integration/Testing/ExpectTest.php @@ -17,11 +17,17 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; +use Playwright\Assertions\LocatorAssertions; +use Playwright\Assertions\PageAssertions; use Playwright\Testing\Expect; +use Playwright\Testing\ExpectDecorator; use Playwright\Testing\PlaywrightTestCaseTrait; use Playwright\Tests\Support\RouteServerTestTrait; #[CoversClass(Expect::class)] +#[CoversClass(ExpectDecorator::class)] +#[CoversClass(LocatorAssertions::class)] +#[CoversClass(PageAssertions::class)] class ExpectTest extends TestCase { use PlaywrightTestCaseTrait; @@ -41,8 +47,9 @@ public function setUp(): void $this->installRouteServer($this->page, [ '/index.html' => <<<'HTML' Expect Test -

Expect Test

+

Expect Test

+
@@ -62,6 +69,7 @@ public function tearDown(): void public function itAssertsVisibility(): void { $expect = $this->expect($this->page->locator('#div-1')); + $expect->withPollInterval(10)->toBeAttached(); $expect->toBeVisible(); $expect = $this->expect($this->page->locator('#div-2')); @@ -153,11 +161,27 @@ public function itAssertsCssProperty(): void { $expect = $this->expect($this->page->locator('#div-1')); $expect->toHaveCSS('width', '50px'); + $expect->toHaveCSS('background-color', 'rgb(0, 0, 255)'); $expect = $this->expect($this->page->locator('#div-1')); $expect->not()->toHaveCSS('width', '10px'); } + #[Test] + public function itAssertsIdentityClassEmptinessAndFocus(): void + { + $heading = $this->expect($this->page->locator('h1')); + $heading->toHaveId('heading'); + $heading->toHaveClass(['title', 'primary']); + + $this->expect($this->page->locator('#empty-element'))->toBeEmpty(); + + $button = $this->page->locator('#button-1'); + $button->focus(); + $this->expect($button)->toBeFocused(); + $this->expect($button)->toHaveFocus(); + } + #[Test] public function itAssertsPageTitleAndUrl(): void { diff --git a/tests/Unit/Assertions/LocatorAssertionsTest.php b/tests/Unit/Assertions/LocatorAssertionsTest.php index ef540fd..56edce2 100644 --- a/tests/Unit/Assertions/LocatorAssertionsTest.php +++ b/tests/Unit/Assertions/LocatorAssertionsTest.php @@ -34,6 +34,34 @@ public function testToBeAttached(): void $this->assertSame($assertions, $assertions->toBeAttached()); } + public function testModifiersAreFluent(): void + { + $assertions = new LocatorAssertions($this->createMock(LocatorInterface::class)); + + $this->assertSame($assertions, $assertions->withTimeout(100)); + $this->assertSame($assertions, $assertions->withPollInterval(10)); + } + + public function testToBeAttachedRetriesAfterAnEvaluationError(): void + { + $locator = $this->createMock(LocatorInterface::class); + $calls = 0; + $locator->expects($this->exactly(2)) + ->method('isAttached') + ->willReturnCallback(static function () use (&$calls): bool { + if (1 === ++$calls) { + throw new \RuntimeException('Detached during evaluation'); + } + + return true; + }); + + $this->assertInstanceOf( + LocatorAssertions::class, + (new LocatorAssertions($locator))->withPollInterval(0)->toBeAttached(), + ); + } + public function testToBeEditable(): void { $locator = $this->createMock(LocatorInterface::class); @@ -51,7 +79,7 @@ public function testToBeAttachedResetsNegationAfterFailure(): void $assertions = new LocatorAssertions($locator); try { - $assertions->not()->toBeAttached(); + $assertions->not()->toBeAttached(new AssertionOptions(timeoutMs: 0)); $this->fail('Expected the negated assertion to fail.'); } catch (AssertionException $exception) { $this->assertSame('Expected locator to be detached.', $exception->getMessage()); @@ -67,7 +95,7 @@ public function testToBeEditableResetsNegationAfterFailure(): void $assertions = new LocatorAssertions($locator); try { - $assertions->not()->toBeEditable(); + $assertions->not()->toBeEditable(new AssertionOptions(timeoutMs: 0)); $this->fail('Expected the negated assertion to fail.'); } catch (AssertionException $exception) { $this->assertSame('Expected locator not to be editable.', $exception->getMessage()); @@ -87,6 +115,54 @@ public function testToBeInViewportUsesConfiguredRatio(): void $this->assertInstanceOf(LocatorAssertions::class, (new LocatorAssertions($locator))->toBeInViewport(new AssertionOptions(ratio: 0.5))); } + public function testToBeInViewportRejectsAnInvalidRatio(): void + { + $this->expectException(\InvalidArgumentException::class); + + (new LocatorAssertions($this->createMock(LocatorInterface::class)))->toBeInViewport(new AssertionOptions(ratio: 1.1)); + } + + public function testToHaveTextMatchesExactlyInTheCanonicalApi(): void + { + $locator = $this->createMock(LocatorInterface::class); + $locator->expects($this->once())->method('textContent')->willReturn('Exact text'); + + $this->assertInstanceOf(LocatorAssertions::class, (new LocatorAssertions($locator))->toHaveText('Exact text')); + } + + public function testToHaveTextSupportsMultipleInnerTexts(): void + { + $locator = $this->createMock(LocatorInterface::class); + $locator->expects($this->once())->method('allInnerTexts')->willReturn(['First', 'Second']); + + $this->assertInstanceOf( + LocatorAssertions::class, + (new LocatorAssertions($locator))->toHaveText( + ['First', 'Second'], + new AssertionOptions(useInnerText: true), + ), + ); + } + + public function testFailureStillThrowsWhenReadingTheActualValueFails(): void + { + $locator = $this->createMock(LocatorInterface::class); + $calls = 0; + $locator->expects($this->exactly(2)) + ->method('count') + ->willReturnCallback(static function () use (&$calls): int { + if (1 === ++$calls) { + return 1; + } + + throw new \RuntimeException('Gone'); + }); + + $this->expectException(AssertionException::class); + + (new LocatorAssertions($locator))->toHaveCount(2, new AssertionOptions(timeoutMs: 0)); + } + public function testToHaveJavaScriptPropertyUsesDeepNativeComparison(): void { $locator = $this->createMock(LocatorInterface::class); diff --git a/tests/Unit/Assertions/PageAssertionsTest.php b/tests/Unit/Assertions/PageAssertionsTest.php index 93572ad..1f59720 100644 --- a/tests/Unit/Assertions/PageAssertionsTest.php +++ b/tests/Unit/Assertions/PageAssertionsTest.php @@ -21,10 +21,53 @@ use Playwright\Assertions\PageAssertions; use Playwright\Locator\LocatorInterface; use Playwright\Page\PageInterface; +use Playwright\Tracing\TracingInterface; #[CoversClass(PageAssertions::class)] final class PageAssertionsTest extends TestCase { + public function testToHaveTitleRetriesAndUsesTracing(): void + { + $page = $this->createMock(PageInterface::class); + $page->expects($this->exactly(2))->method('title')->willReturn('Loading', 'Ready'); + + $tracing = $this->createMock(TracingInterface::class); + $tracing->expects($this->once())->method('group')->with('expect(page).toHaveTitle'); + $tracing->expects($this->once())->method('groupEnd'); + + $assertions = new PageAssertions($page, $tracing); + + $this->assertSame($assertions, $assertions->withTimeout(100)->withPollInterval(0)->toHaveTitle('Ready')); + } + + public function testToHaveUrlSupportsNegation(): void + { + $page = $this->createMock(PageInterface::class); + $page->expects($this->once())->method('url')->willReturn('https://example.com/'); + + $assertions = new PageAssertions($page); + + $this->assertSame($assertions, $assertions->not()->toHaveURL('https://example.com/login')); + } + + public function testFailureUsesAssertionOptionsAndResetsNegation(): void + { + $page = $this->createMock(PageInterface::class); + $page->expects($this->exactly(3))->method('title')->willReturn('Actual'); + $assertions = new PageAssertions($page); + + try { + $assertions->not()->toHaveTitle('Actual', new AssertionOptions(timeoutMs: 0, message: 'Custom failure')); + $this->fail('Expected the negated assertion to fail.'); + } catch (AssertionException $exception) { + $this->assertSame('Custom failure', $exception->getMessage()); + $this->assertSame('Actual', $exception->actual); + $this->assertSame('Actual', $exception->expected); + } + + $this->assertSame($assertions, $assertions->toHaveTitle('Actual')); + } + public function testToMatchAriaSnapshotSnapshotsTheBody(): void { $locator = $this->createMock(LocatorInterface::class); diff --git a/tests/Unit/Testing/ExpectFactoryTest.php b/tests/Unit/Testing/ExpectFactoryTest.php index 838ec86..bb0c072 100644 --- a/tests/Unit/Testing/ExpectFactoryTest.php +++ b/tests/Unit/Testing/ExpectFactoryTest.php @@ -14,11 +14,15 @@ namespace Playwright\Tests\Unit\Testing; +use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\CoversFunction; use PHPUnit\Framework\Attributes\CoversTrait; use PHPUnit\Framework\TestCase; use Playwright\Browser\BrowserContextInterface; use Playwright\Locator\LocatorInterface; +use Playwright\Page\PageInterface; +use Playwright\Testing\Expect; +use Playwright\Testing\ExpectDecorator; use Playwright\Testing\ExpectInterface; use Playwright\Testing\PlaywrightTestCaseTrait; use Playwright\Tracing\TracingInterface; @@ -27,6 +31,8 @@ #[CoversTrait(PlaywrightTestCaseTrait::class)] #[CoversFunction('Playwright\Testing\expect')] +#[CoversClass(Expect::class)] +#[CoversClass(ExpectDecorator::class)] final class ExpectFactoryTest extends TestCase { private function createVisibleLocator(): LocatorInterface @@ -58,7 +64,9 @@ public function callExpect(BrowserContextInterface $context, LocatorInterface $s } }; - $harness->callExpect($context, $this->createVisibleLocator())->toBeVisible(); + $harness->callExpect($context, $this->createVisibleLocator()) + ->withTimeout(100) + ->toBeVisible(); } public function testTheExpectFunctionAcceptsATracingHandle(): void @@ -76,4 +84,47 @@ public function testTheExpectFunctionWorksWithoutTracing(): void $this->assertTrue(true); } + + public function testTheExpectFunctionExposesLocatorAssertions(): void + { + $locator = $this->createMock(LocatorInterface::class); + $locator->expects($this->once())->method('isAttached')->willReturn(true); + $locator->method('getSelector')->willReturn('#x'); + + expect($locator)->toBeAttached(); + } + + public function testTheExpectFunctionKeepsLegacyToHaveTextContainsSemantics(): void + { + $locator = $this->createMock(LocatorInterface::class); + $locator->expects($this->once())->method('textContent')->willReturn('Welcome Simon'); + $locator->method('getSelector')->willReturn('#x'); + + expect($locator)->toHaveText('Welcome'); + } + + public function testLocatorAssertionsRejectAPageSubject(): void + { + $this->expectException(\InvalidArgumentException::class); + + expect($this->createMock(PageInterface::class))->toBeVisible(); + } + + public function testPageAssertionsRejectALocatorSubject(): void + { + $this->expectException(\InvalidArgumentException::class); + + expect($this->createVisibleLocator())->toHaveTitle('Title'); + } + + public function testNegationDoesNotLeakToTheNextAssertion(): void + { + $locator = $this->createMock(LocatorInterface::class); + $locator->expects($this->exactly(2))->method('isVisible')->willReturn(false, true); + $locator->method('getSelector')->willReturn('#x'); + $expect = expect($locator)->withPollInterval(0); + + $expect->not()->toBeVisible(); + $expect->toBeVisible(); + } } diff --git a/tests/Unit/Testing/ExpectToHaveClassTest.php b/tests/Unit/Testing/ExpectToHaveClassTest.php index 24b93d0..3d238c2 100644 --- a/tests/Unit/Testing/ExpectToHaveClassTest.php +++ b/tests/Unit/Testing/ExpectToHaveClassTest.php @@ -90,7 +90,7 @@ public function testAMissingClassAttributeFails(): void public function testANegatedAssertionFailsOnTheExactList(): void { $this->expectException(\Throwable::class); - $this->expectExceptionMessage('Expected class list not to be'); + $this->expectExceptionMessage('Expected locator class list not to match.'); (new Expect($this->locatorWithClass('primary active'))) ->not()->withTimeout(0)->toHaveClass('primary active');