From 09ecb31c82dd17484af0fc6ee53cc4c07c80154e Mon Sep 17 00:00:00 2001 From: Mohammad Saddam Hossain Date: Sun, 20 Sep 2026 11:12:08 +0530 Subject: [PATCH] feat(locator): terminal actions wait for presence and re-resolve on every poll Extends the click() stale-proofing (#67) to type, append, getText, getAttribute, hover, scrollIntoView, jsClick and element(), via one shared whenReady() helper. Behaviour change: a locator that matches nothing is now retried until timeouts.explicit instead of throwing immediately. It still ends in a LocatorException (cause: TimeoutException), so existing catches keep working. isVisible/isHidden/isEnabled/count still never wait. rows() resolves through element() and inherits the wait for the table itself. Co-Authored-By: Claude Sonnet 5 --- docs-site/docs/guides/semantic-locators.md | 6 ++ .../com/seleniumboot/locator/Locator.java | 93 +++++++++++------- .../unit/LocatorStaleElementTest.java | 97 +++++++++++++++++++ 3 files changed, 163 insertions(+), 33 deletions(-) diff --git a/docs-site/docs/guides/semantic-locators.md b/docs-site/docs/guides/semantic-locators.md index 3231ee9..5065308 100644 --- a/docs-site/docs/guides/semantic-locators.md +++ b/docs-site/docs/guides/semantic-locators.md @@ -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? diff --git a/src/main/java/com/seleniumboot/locator/Locator.java b/src/main/java/com/seleniumboot/locator/Locator.java index 3c42697..e00a58a 100644 --- a/src/main/java/com/seleniumboot/locator/Locator.java +++ b/src/main/java/com/seleniumboot/locator/Locator.java @@ -7,9 +7,9 @@ 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; @@ -17,6 +17,9 @@ 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; /** @@ -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. + * + *

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. */ @@ -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). */ @@ -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. */ @@ -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 whenVisible(Function 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 whenReady(Predicate ready, Function action) { int timeout = SeleniumBootContext.getConfig().getTimeouts().getExplicit(); - return new WebDriverWait(driver(), Duration.ofSeconds(timeout)) - .until(ExpectedConditions.elementToBeClickable(el)); + AtomicReference 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(); } // ------------------------------------------------------------------ diff --git a/src/test/java/com/seleniumboot/unit/LocatorStaleElementTest.java b/src/test/java/com/seleniumboot/unit/LocatorStaleElementTest.java index 2a302e0..216f097 100644 --- a/src/test/java/com/seleniumboot/unit/LocatorStaleElementTest.java +++ b/src/test/java/com/seleniumboot/unit/LocatorStaleElementTest.java @@ -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)); + } }