Skip to content
Open
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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/).

- **Bounded failure detail record** — every failed test now exports one optional-field `failureDetail` object with the failing locator, top application frame, last successful step, page URL/title, heal events, console errors, retry/history status, and representative same-cause context. The HTML failure panel and opt-in AI prompt reuse this record; raw errors and stack traces remain available. Text/list/URL limits and the exact AI data disclosure are documented. (#142)

- **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)

- **`Locator`: `waitFor(State)`, `allTextContents()`, and element `screenshot()`** — `waitFor(State)` (and `waitFor()`) waits for an element to reach a target lifecycle state (`VISIBLE`, `HIDDEN`, `ATTACHED`, `DETACHED`), retrying up to `timeouts.explicit`. `allTextContents()` returns the text content of all matched elements in a single pass without waiting if empty, re-resolving if an element goes stale during read. `screenshot()` returns the element's PNG bytes, and `screenshot(Path)` writes them to a file path; both terminal actions re-resolve on every poll to survive stale elements. (#135)

- **`flakiness.historyDir` / `-Dseleniumboot.history.dir`** — metrics history can now live outside `target/`. Builds that clean `target/` or discard the CI workspace between runs had no history, so the Flakiness Radar and the newly/consistently-failing and intermittent statuses stayed empty. Point it at a directory your CI restores and saves (a cache or artifact store); the system property wins over the config. Default unchanged: `<reports dir>/metrics-history`. (#141)
Expand Down
2 changes: 2 additions & 0 deletions docs-site/docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ All notable changes to Selenium Boot are documented here.

- **Bounded failure detail record** — every failed test now exports one optional-field `failureDetail` object with the failing locator, top application frame, last successful step, page URL/title, heal events, console errors, retry/history status, and representative same-cause context. The HTML failure panel and opt-in AI prompt reuse this record; raw errors and stack traces remain available. Text/list/URL limits and the exact AI data disclosure are documented. (#142)

- **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)

- **`Locator`: `waitFor(State)`, `allTextContents()`, and element `screenshot()`** — `waitFor(State)` (and `waitFor()`) waits for an element to reach a target lifecycle state (`VISIBLE`, `HIDDEN`, `ATTACHED`, `DETACHED`), retrying up to `timeouts.explicit`. `allTextContents()` returns the text content of all matched elements in a single pass without waiting if empty, re-resolving if an element goes stale during read. `screenshot()` returns the element's PNG bytes, and `screenshot(Path)` writes them to a file path; both terminal actions re-resolve on every poll to survive stale elements. (#135)

- **`flakiness.historyDir` / `-Dseleniumboot.history.dir`** — metrics history can now live outside `target/`. Builds that clean `target/` or discard the CI workspace between runs had no history, so the Flakiness Radar and the newly/consistently-failing and intermittent statuses stayed empty. Point it at a directory your CI restores and saves (a cache or artifact store); the system property wins over the config. Default unchanged: `<reports dir>/metrics-history`. (#141)
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]"));
}
}
Loading