From 9833a0b877f4d88ede1a32bb990bb8689dd078e3 Mon Sep 17 00:00:00 2001 From: Aditya Datta Date: Mon, 12 Oct 2026 21:52:20 +0530 Subject: [PATCH] feat: add focused empty and regex assertions (#136) --- docs-site/docs/changelog.md | 2 + docs-site/docs/guides/semantic-locators.md | 2 + .../seleniumboot/assertion/LocatorAssert.java | 108 ++++++++++++++++- .../unit/LocatorAssertBehaviorTest.java | 109 ++++++++++++++++++ 4 files changed, 220 insertions(+), 1 deletion(-) create mode 100644 src/test/java/com/seleniumboot/unit/LocatorAssertBehaviorTest.java diff --git a/docs-site/docs/changelog.md b/docs-site/docs/changelog.md index f940300..6c6c4d7 100644 --- a/docs-site/docs/changelog.md +++ b/docs-site/docs/changelog.md @@ -11,6 +11,8 @@ All notable changes to Selenium Boot are documented here. ## [Unreleased] +- **Richer auto-retrying locator assertions** — `LocatorAssert` adds `isFocused()` and `isEmpty()`, plus `Pattern` overloads for `hasText`, `containsText`, and `hasValue`. Regex failures report the locator and last observed value, and stale elements are re-resolved until `timeouts.explicit`. (#136) + - **Failure grouping** — the report's Failures tab now opens with a "N tests, same cause" panel: failed tests are grouped by a signature of category, exception type, normalised message (ids, numbers and quoted values stripped) and top application stack frame. The flat per-class list is unchanged below it. With `ai.failureAnalysis` on, one Claude call is made per group; the other tests in the group reuse its analysis. The metrics JSON gains `failureSignature`. (#140) - **`Locator` form actions: `clear()`, `check()`, `uncheck()`, `isChecked()`, `selectOption(text)`, `selectOptionByValue(value)`, `inputValue()`** — form controls can now be manipulated and inspected directly on `Locator` without falling back to `BasePage` helpers or raw `WebElement`s. `check()` and `uncheck()` are idempotent (no click if already in the target state), `isChecked()` queries checked state without waiting, and terminal actions (`clear`, `check`, `uncheck`, `selectOption`, `selectOptionByValue`, `inputValue`) auto-wait, re-resolving from scratch on every poll to survive stale or late-appearing elements. (#133) diff --git a/docs-site/docs/guides/semantic-locators.md b/docs-site/docs/guides/semantic-locators.md index b7f9292..7c09bc7 100644 --- a/docs-site/docs/guides/semantic-locators.md +++ b/docs-site/docs/guides/semantic-locators.md @@ -24,6 +24,8 @@ Every semantic locator returns the same chainable, **auto-waiting** `Locator` used by the `$()` API — no `Thread.sleep`, no explicit waits. They're available on both `BaseTest` and `BasePage`. Narrow multiple matches using `.first()`, `.last()`, or `.nth(index)` (e.g. `getByRole(Role.LISTITEM).first().click()`). +Assertions can retry focus and empty-state checks, and accept regular expressions for text and values: `assertThat(getByLabel("Email")).isFocused()`, `isEmpty()`, or `hasValue(Pattern.compile("user-\\d+"))`. + Auto-waiting covers the element *existing* as well as being visible: an action such as `click()` or `type()` keeps looking for the element until `timeouts.explicit` elapses, and looks it up afresh each time, so a page that re-renders it mid-action doesn't cause a stale-element error. If it never diff --git a/src/main/java/com/seleniumboot/assertion/LocatorAssert.java b/src/main/java/com/seleniumboot/assertion/LocatorAssert.java index 1818b2d..c8a79ef 100644 --- a/src/main/java/com/seleniumboot/assertion/LocatorAssert.java +++ b/src/main/java/com/seleniumboot/assertion/LocatorAssert.java @@ -5,6 +5,7 @@ import com.seleniumboot.internal.SeleniumBootContext; import com.seleniumboot.locator.Locator; import org.openqa.selenium.By; +import org.openqa.selenium.StaleElementReferenceException; import org.openqa.selenium.WebDriver; import org.openqa.selenium.WebElement; import org.openqa.selenium.support.ui.ExpectedCondition; @@ -14,6 +15,9 @@ import java.time.Duration; import java.util.List; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Supplier; +import java.util.regex.Pattern; /** * Fluent, auto-retrying assertion for a specific locator. @@ -96,6 +100,47 @@ public LocatorAssert isChecked() { return this; } + /** Asserts the element owns browser focus — retries until timeout. */ + public LocatorAssert isFocused() { + AtomicReference actual = new AtomicReference<>("no matching element"); + poll(driver -> { + try { + List els = driver.findElements(by); + if (els.isEmpty()) return null; + WebElement focused = driver.switchTo().activeElement(); + actual.set(describeElement(focused)); + return els.get(0).equals(focused) ? true : null; + } catch (StaleElementReferenceException e) { + actual.set("stale element"); + return null; + } + }, "Expected element to be focused: " + description, actual::get); + return this; + } + + /** + * Asserts the element is empty — inputs use their {@code value}; other + * elements use visible text. Retries until timeout. + */ + public LocatorAssert isEmpty() { + AtomicReference actual = new AtomicReference<>("no matching element"); + poll(driver -> { + try { + List els = driver.findElements(by); + if (els.isEmpty()) return null; + WebElement el = els.get(0); + String tag = el.getTagName(); + String content = isFormValueElement(tag) ? el.getAttribute("value") : el.getText(); + actual.set(content == null ? "null" : content); + return content == null || content.isEmpty() ? true : null; + } catch (StaleElementReferenceException e) { + actual.set("stale element"); + return null; + } + }, "Expected element to be empty: " + description, actual::get); + return this; + } + // ------------------------------------------------------------------ // Text // ------------------------------------------------------------------ @@ -107,6 +152,11 @@ public LocatorAssert hasText(String expected) { return this; } + /** Asserts the element's visible text fully matches {@code pattern} — retries until timeout. */ + public LocatorAssert hasText(Pattern pattern) { + return matches(pattern, false, false); + } + /** Asserts the element's visible text contains {@code fragment} — retries until timeout. */ public LocatorAssert containsText(String fragment) { poll(ExpectedConditions.textToBePresentInElementLocated(by, fragment), @@ -114,6 +164,11 @@ public LocatorAssert containsText(String fragment) { return this; } + /** Asserts some part of the element's visible text matches {@code pattern} — retries until timeout. */ + public LocatorAssert containsText(Pattern pattern) { + return matches(pattern, true, false); + } + // ------------------------------------------------------------------ // Attributes & CSS // ------------------------------------------------------------------ @@ -125,6 +180,11 @@ public LocatorAssert hasValue(String expected) { return this; } + /** Asserts the element's {@code value} fully matches {@code pattern} — retries until timeout. */ + public LocatorAssert hasValue(Pattern pattern) { + return matches(pattern, false, true); + } + /** Asserts the element has a specific attribute value — retries until timeout. */ public LocatorAssert hasAttribute(String attribute, String expected) { poll(ExpectedConditions.attributeToBe(by, attribute, expected), @@ -163,12 +223,58 @@ public LocatorAssert count(int expected) { // ------------------------------------------------------------------ private void poll(ExpectedCondition condition, String failMessage) { + poll(condition, failMessage, null); + } + + private void poll(ExpectedCondition condition, String failMessage, + Supplier actualValue) { int timeout = SeleniumBootContext.getConfig().getTimeouts().getExplicit(); WebDriver driver = DriverManager.getDriver(); try { new WebDriverWait(driver, Duration.ofSeconds(timeout)).until(condition); } catch (Exception e) { - Assert.fail(failMessage + " (timeout: " + timeout + "s)"); + String actual = actualValue == null ? "" : "; actual: [" + actualValue.get() + "]"; + Assert.fail(failMessage + actual + " (timeout: " + timeout + "s)"); + } + } + + private LocatorAssert matches(Pattern pattern, boolean find, boolean valueAttribute) { + AtomicReference actual = new AtomicReference<>("no matching element"); + String subject = valueAttribute ? "value" : "text"; + poll(driver -> { + try { + List els = driver.findElements(by); + if (els.isEmpty()) return null; + String candidate = valueAttribute + ? els.get(0).getAttribute("value") + : els.get(0).getText(); + actual.set(candidate == null ? "null" : candidate); + if (candidate == null) return null; + var matcher = pattern.matcher(candidate); + return (find ? matcher.find() : matcher.matches()) ? true : null; + } catch (StaleElementReferenceException e) { + actual.set("stale element"); + return null; + } + }, "Expected " + subject + " to " + (find ? "contain a match for " : "match ") + + "[" + pattern + "] for: " + description, actual::get); + return this; + } + + private static boolean isFormValueElement(String tag) { + return "input".equalsIgnoreCase(tag) + || "textarea".equalsIgnoreCase(tag) + || "select".equalsIgnoreCase(tag); + } + + private static String describeElement(WebElement element) { + if (element == null) return "none"; + try { + String tag = element.getTagName(); + String id = element.getAttribute("id"); + return id == null || id.isEmpty() ? "<" + tag + ">" : "<" + tag + " id=\"" + id + "\">"; + } catch (StaleElementReferenceException e) { + return "stale element"; } } } diff --git a/src/test/java/com/seleniumboot/unit/LocatorAssertBehaviorTest.java b/src/test/java/com/seleniumboot/unit/LocatorAssertBehaviorTest.java new file mode 100644 index 0000000..8933e0f --- /dev/null +++ b/src/test/java/com/seleniumboot/unit/LocatorAssertBehaviorTest.java @@ -0,0 +1,109 @@ +package com.seleniumboot.unit; + +import com.seleniumboot.assertion.SeleniumAssert; +import com.seleniumboot.config.SeleniumBootConfig; +import com.seleniumboot.driver.DriverManager; +import com.seleniumboot.internal.SeleniumBootContext; +import org.mockito.MockedStatic; +import org.openqa.selenium.By; +import org.openqa.selenium.StaleElementReferenceException; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.WebElement; +import org.testng.annotations.AfterMethod; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +import java.util.List; +import java.util.regex.Pattern; + +import static org.mockito.Mockito.*; +import static org.testng.Assert.expectThrows; +import static org.testng.Assert.assertTrue; + +public class LocatorAssertBehaviorTest { + + private WebDriver driver; + private MockedStatic driverManagerMock; + private MockedStatic contextMock; + + @BeforeMethod + public void setup() { + driver = mock(WebDriver.class); + driverManagerMock = mockStatic(DriverManager.class); + driverManagerMock.when(DriverManager::getDriver).thenReturn(driver); + + SeleniumBootConfig.Timeouts timeouts = new SeleniumBootConfig.Timeouts(); + timeouts.setExplicit(1); + SeleniumBootConfig config = new SeleniumBootConfig(); + config.setTimeouts(timeouts); + contextMock = mockStatic(SeleniumBootContext.class); + contextMock.when(SeleniumBootContext::getConfig).thenReturn(config); + } + + @AfterMethod + public void teardown() { + driverManagerMock.close(); + contextMock.close(); + } + + @Test + public void focusedAndEmptyAssertionsUseLiveElementState() { + By focusedBy = By.id("search"); + WebElement focused = mock(WebElement.class); + WebDriver.TargetLocator target = mock(WebDriver.TargetLocator.class); + when(driver.findElements(focusedBy)).thenReturn(List.of(focused)); + when(driver.switchTo()).thenReturn(target); + when(target.activeElement()).thenReturn(focused); + + SeleniumAssert.assertThat(focusedBy).isFocused(); + + By emptyBy = By.id("email"); + WebElement emptyInput = mock(WebElement.class); + when(driver.findElements(emptyBy)).thenReturn(List.of(emptyInput)); + when(emptyInput.getTagName()).thenReturn("input"); + when(emptyInput.getAttribute("value")).thenReturn(""); + + SeleniumAssert.assertThat(emptyBy).isEmpty(); + } + + @Test + public void regexAssertionsSupportFullAndPartialMatches() { + By by = By.id("status"); + WebElement element = mock(WebElement.class); + when(driver.findElements(by)).thenReturn(List.of(element)); + when(element.getText()).thenReturn("Order 412 is ready"); + when(element.getAttribute("value")).thenReturn("user-42"); + + SeleniumAssert.assertThat(by).hasText(Pattern.compile("Order \\d+ is ready")); + SeleniumAssert.assertThat(by).containsText(Pattern.compile("\\d+")); + SeleniumAssert.assertThat(by).hasValue(Pattern.compile("user-\\d+")); + } + + @Test + public void regexAssertionReResolvesAfterStaleElement() { + By by = By.id("status"); + WebElement stale = mock(WebElement.class); + WebElement fresh = mock(WebElement.class); + when(stale.getText()).thenThrow(new StaleElementReferenceException("re-rendered")); + when(fresh.getText()).thenReturn("ready"); + when(driver.findElements(by)).thenReturn(List.of(stale)).thenReturn(List.of(fresh)); + + SeleniumAssert.assertThat(by).hasText(Pattern.compile("ready")); + + verify(driver, atLeast(2)).findElements(by); + } + + @Test + public void regexFailureNamesLocatorAndActualValue() { + By by = By.id("status"); + WebElement element = mock(WebElement.class); + when(driver.findElements(by)).thenReturn(List.of(element)); + when(element.getText()).thenReturn("pending"); + + AssertionError error = expectThrows(AssertionError.class, + () -> SeleniumAssert.assertThat(by).hasText(Pattern.compile("ready"))); + + assertTrue(error.getMessage().contains("By.id: status")); + assertTrue(error.getMessage().contains("actual: [pending]")); + } +}