Skip to content
Merged
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
6 changes: 6 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,12 @@ 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`.

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
appears you get a `LocatorException` after the timeout. `isVisible()`, `isHidden()`, `isEnabled()`
and `count()` never wait.

---

## Why semantic locators?
Expand Down
93 changes: 60 additions & 33 deletions src/main/java/com/seleniumboot/locator/Locator.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,19 @@
import org.openqa.selenium.JavascriptExecutor;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.StaleElementReferenceException;
import org.openqa.selenium.TimeoutException;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.interactions.Actions;
import org.openqa.selenium.support.ui.ExpectedConditions;
import org.openqa.selenium.support.ui.WebDriverWait;

import java.time.Duration;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.stream.Collectors;

/**
Expand Down Expand Up @@ -223,42 +226,39 @@ public By toBy() {
// ------------------------------------------------------------------

/**
* Waits for the element to be clickable, then clicks it. The element is re-resolved on every
* poll, so a page that re-renders it between lookup and click does not surface as a
* {@link StaleElementReferenceException}.
* Waits for the element to exist and be clickable, then clicks it.
*
* <p>Like every terminal action, the element is re-resolved on each poll: one that is not in
* the DOM yet is waited for, and one the page re-renders between lookup and action does not
* surface as a {@link StaleElementReferenceException}. Both are bounded by
* {@code timeouts.explicit}.
*/
public void click() {
int timeout = SeleniumBootContext.getConfig().getTimeouts().getExplicit();
new WebDriverWait(driver(), Duration.ofSeconds(timeout))
.ignoring(StaleElementReferenceException.class)
.until(d -> {
WebElement el = ExpectedConditions.elementToBeClickable(resolve()).apply(d);
if (el == null) return null;
el.click();
return Boolean.TRUE;
});
whenReady(el -> el.isDisplayed() && el.isEnabled(), el -> { el.click(); return null; });
}

/** Waits for the element to be visible, clears it, then types the given text. */
public void type(String text) {
WebElement el = waitForVisible(resolve());
el.clear();
el.sendKeys(text);
whenVisible(el -> {
el.clear();
el.sendKeys(text);
return null;
});
}

/** Appends text without clearing first. */
public void append(String text) {
waitForVisible(resolve()).sendKeys(text);
whenVisible(el -> { el.sendKeys(text); return null; });
}

/** Waits for the element to be visible and returns its trimmed visible text. */
public String getText() {
return waitForVisible(resolve()).getText().trim();
return whenVisible(el -> el.getText().trim());
}

/** Returns the value of the given attribute, waiting for visibility first. */
public String getAttribute(String name) {
return waitForVisible(resolve()).getAttribute(name);
return whenVisible(el -> el.getAttribute(name));
}

/** Returns true if the element is present and displayed — does NOT wait. */
Expand Down Expand Up @@ -286,20 +286,23 @@ public boolean isEnabled() {

/** Hovers over the element using Actions. */
public void hover() {
WebElement el = waitForVisible(resolve());
new Actions(driver()).moveToElement(el).perform();
whenVisible(el -> { new Actions(driver()).moveToElement(el).perform(); return null; });
}

/** Scrolls the element into view using JavaScript. */
public void scrollIntoView() {
WebElement el = waitForVisible(resolve());
((JavascriptExecutor) driver()).executeScript("arguments[0].scrollIntoView(true);", el);
whenVisible(el -> {
((JavascriptExecutor) driver()).executeScript("arguments[0].scrollIntoView(true);", el);
return null;
});
}

/** Clicks using JavaScript — useful when element is obscured. */
public void jsClick() {
WebElement el = waitForVisible(resolve());
((JavascriptExecutor) driver()).executeScript("arguments[0].click();", el);
whenVisible(el -> {
((JavascriptExecutor) driver()).executeScript("arguments[0].click();", el);
return null;
});
}

/** Returns the number of elements currently matching the locator chain (no wait). */
Expand All @@ -309,7 +312,7 @@ public int count() {

/** Returns the resolved {@link WebElement}, applying all chain filters. */
public WebElement element() {
return waitForVisible(resolve());
return whenVisible(el -> el);
}

/** Returns all matched {@link WebElement}s, applying all chain filters. */
Expand Down Expand Up @@ -654,16 +657,40 @@ private boolean nameMatches(WebDriver d, WebElement el, String expected) {
// Wait helpers
// ------------------------------------------------------------------

private WebElement waitForVisible(WebElement el) {
int timeout = SeleniumBootContext.getConfig().getTimeouts().getExplicit();
return new WebDriverWait(driver(), Duration.ofSeconds(timeout))
.until(ExpectedConditions.visibilityOf(el));
private <T> T whenVisible(Function<WebElement, T> action) {
return whenReady(WebElement::isDisplayed, action);
}

private WebElement waitForClickable(WebElement el) {
/**
* Polls until an element resolves, satisfies {@code ready}, and {@code action} runs on it —
* re-resolving from scratch on every poll. A missing element ({@link LocatorException}) and a
* stale one ({@link StaleElementReferenceException}) are retried until
* {@code timeouts.explicit} elapses. A locator that never matched then throws
* {@link LocatorException} (cause: the {@code TimeoutException}); anything else throws the
* {@code TimeoutException}. The action's
* result may be {@code null}.
*/
private <T> T whenReady(Predicate<WebElement> ready, Function<WebElement, T> action) {
int timeout = SeleniumBootContext.getConfig().getTimeouts().getExplicit();
return new WebDriverWait(driver(), Duration.ofSeconds(timeout))
.until(ExpectedConditions.elementToBeClickable(el));
AtomicReference<T> result = new AtomicReference<>();
try {
new WebDriverWait(driver(), Duration.ofSeconds(timeout))
.ignoring(StaleElementReferenceException.class, LocatorException.class)
.until(d -> {
WebElement el = resolve();
if (!ready.test(el)) return null;
result.set(action.apply(el));
return Boolean.TRUE;
});
} catch (TimeoutException e) {
// A locator that never matched still surfaces as LocatorException, as it did before
// the wait existed, so callers catching it keep working.
if (e.getCause() instanceof LocatorException le) {
throw new LocatorException(le.getMessage() + " (waited " + timeout + "s)", e);
}
throw e;
}
return result.get();
}

// ------------------------------------------------------------------
Expand Down
97 changes: 97 additions & 0 deletions src/test/java/com/seleniumboot/unit/LocatorStaleElementTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -93,4 +93,101 @@ public void click_elementNeverGoesStale_clicksExactlyOnce() {
assertEquals(mockingDetails(driver).getInvocations().stream()
.filter(i -> i.getMethod().getName().equals("findElements")).count(), 1L);
}

// ------------------------------------------------------------------
// Presence wait + the remaining terminal actions
// ------------------------------------------------------------------

@Test
public void click_elementNotInDomYet_waitsForItToAppear() {
WebElement fresh = liveElement();
By by = By.id("late");
when(driver.findElements(by)).thenReturn(List.of()).thenReturn(List.of()).thenReturn(List.of(fresh));

Locator.of(by).click();

verify(fresh, times(1)).click();
}

@Test
public void click_elementNeverAppears_throwsLocatorExceptionCausedByTimeout() {
By by = By.id("never");
when(driver.findElements(by)).thenReturn(List.of());

try {
Locator.of(by).click();
org.testng.Assert.fail("expected LocatorException");
} catch (com.seleniumboot.locator.LocatorException e) {
org.testng.Assert.assertTrue(e.getMessage().contains("No element found"), e.getMessage());
org.testng.Assert.assertTrue(e.getMessage().contains("waited 2s"), e.getMessage());
org.testng.Assert.assertTrue(e.getCause() instanceof org.openqa.selenium.TimeoutException);
}
}

@Test
public void getText_elementGoesStale_reResolves() {
WebElement stale = mock(WebElement.class);
when(stale.isDisplayed()).thenReturn(true);
when(stale.getText()).thenThrow(new StaleElementReferenceException("re-rendered"));
WebElement fresh = liveElement();
when(fresh.getText()).thenReturn(" hello ");
By by = By.id("label");
when(driver.findElements(by)).thenReturn(List.of(stale)).thenReturn(List.of(fresh));

assertEquals(Locator.of(by).getText(), "hello");
}

@Test
public void getAttribute_nullValueIsReturnedNotRetried() {
WebElement el = liveElement();
when(el.getAttribute("data-x")).thenReturn(null);
By by = By.id("attr");
when(driver.findElements(by)).thenReturn(List.of(el));

org.testng.Assert.assertNull(Locator.of(by).getAttribute("data-x"));
verify(el, times(1)).getAttribute("data-x");
}

@Test
public void type_elementGoesStale_reResolvesAndTypesIntoFreshElement() {
WebElement stale = liveElement();
doThrow(new StaleElementReferenceException("re-rendered")).when(stale).clear();
WebElement fresh = liveElement();
By by = By.id("name");
when(driver.findElements(by)).thenReturn(List.of(stale)).thenReturn(List.of(fresh));

Locator.of(by).type("abc");

verify(fresh).clear();
verify(fresh).sendKeys("abc");
}

@Test
public void append_elementGoesStale_reResolves() {
WebElement stale = liveElement();
doThrow(new StaleElementReferenceException("re-rendered")).when(stale).sendKeys("x");
WebElement fresh = liveElement();
By by = By.id("name");
when(driver.findElements(by)).thenReturn(List.of(stale)).thenReturn(List.of(fresh));

Locator.of(by).append("x");

verify(fresh).sendKeys("x");
}

@Test
public void jsClick_elementGoesStale_reResolves() {
WebDriver jsDriver = mock(WebDriver.class, withSettings().extraInterfaces(org.openqa.selenium.JavascriptExecutor.class));
org.openqa.selenium.JavascriptExecutor js = (org.openqa.selenium.JavascriptExecutor) jsDriver;
driverManagerMock.when(DriverManager::getDriver).thenReturn(jsDriver);
WebElement stale = liveElement();
WebElement fresh = liveElement();
By by = By.id("obscured");
when(jsDriver.findElements(by)).thenReturn(List.of(stale)).thenReturn(List.of(fresh));
when(js.executeScript(anyString(), eq(stale))).thenThrow(new StaleElementReferenceException("re-rendered"));

Locator.of(by).jsClick();

verify(js).executeScript(anyString(), eq(fresh));
}
}
Loading