Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs-site/docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions docs-site/docs/guides/semantic-locators.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
108 changes: 107 additions & 1 deletion src/main/java/com/seleniumboot/assertion/LocatorAssert.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.
Expand Down Expand Up @@ -96,6 +100,47 @@ public LocatorAssert isChecked() {
return this;
}

/** Asserts the element owns browser focus — retries until timeout. */
public LocatorAssert isFocused() {
AtomicReference<String> actual = new AtomicReference<>("no matching element");
poll(driver -> {
try {
List<WebElement> 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<String> actual = new AtomicReference<>("no matching element");
poll(driver -> {
try {
List<WebElement> 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
// ------------------------------------------------------------------
Expand All @@ -107,13 +152,23 @@ 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),
"Expected text to contain [" + fragment + "] for: " + description);
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
// ------------------------------------------------------------------
Expand All @@ -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),
Expand Down Expand Up @@ -163,12 +223,58 @@ public LocatorAssert count(int expected) {
// ------------------------------------------------------------------

private <T> void poll(ExpectedCondition<T> condition, String failMessage) {
poll(condition, failMessage, null);
}

private <T> void poll(ExpectedCondition<T> condition, String failMessage,
Supplier<String> 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<String> actual = new AtomicReference<>("no matching element");
String subject = valueAttribute ? "value" : "text";
poll(driver -> {
try {
List<WebElement> 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";
}
}
}
109 changes: 109 additions & 0 deletions src/test/java/com/seleniumboot/unit/LocatorAssertBehaviorTest.java
Original file line number Diff line number Diff line change
@@ -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<DriverManager> driverManagerMock;
private MockedStatic<SeleniumBootContext> 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]"));
}
}