diff --git a/src/main/java/com/seleniumboot/locator/Locator.java b/src/main/java/com/seleniumboot/locator/Locator.java index 09c223c..3c42697 100644 --- a/src/main/java/com/seleniumboot/locator/Locator.java +++ b/src/main/java/com/seleniumboot/locator/Locator.java @@ -6,6 +6,7 @@ import org.openqa.selenium.By; import org.openqa.selenium.JavascriptExecutor; import org.openqa.selenium.WebDriver; +import org.openqa.selenium.StaleElementReferenceException; import org.openqa.selenium.WebElement; import org.openqa.selenium.interactions.Actions; import org.openqa.selenium.support.ui.ExpectedConditions; @@ -221,9 +222,21 @@ public By toBy() { // Terminal actions — all auto-wait // ------------------------------------------------------------------ - /** Waits for the element to be clickable, then clicks it. */ + /** + * 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}. + */ public void click() { - waitForClickable(resolve()).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; + }); } /** Waits for the element to be visible, clears it, then types the given text. */ diff --git a/src/test/java/com/seleniumboot/unit/LocatorStaleElementTest.java b/src/test/java/com/seleniumboot/unit/LocatorStaleElementTest.java new file mode 100644 index 0000000..2a302e0 --- /dev/null +++ b/src/test/java/com/seleniumboot/unit/LocatorStaleElementTest.java @@ -0,0 +1,96 @@ +package com.seleniumboot.unit; + +import com.seleniumboot.config.SeleniumBootConfig; +import com.seleniumboot.driver.DriverManager; +import com.seleniumboot.internal.SeleniumBootContext; +import com.seleniumboot.locator.Locator; +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 static org.mockito.Mockito.*; +import static org.testng.Assert.assertEquals; + +/** + * {@link Locator} terminal actions must survive the page re-rendering the element between + * resolving it and acting on it — they re-resolve instead of reusing a stale reference. + */ +public class LocatorStaleElementTest { + + 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(2); + 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(); + } + + private static WebElement liveElement() { + WebElement el = mock(WebElement.class); + when(el.isDisplayed()).thenReturn(true); + when(el.isEnabled()).thenReturn(true); + return el; + } + + @Test + public void click_elementGoesStaleBetweenCheckAndClick_reResolvesAndClicksFreshElement() { + WebElement stale = liveElement(); + doThrow(new StaleElementReferenceException("re-rendered")).when(stale).click(); + WebElement fresh = liveElement(); + By by = By.id("save"); + when(driver.findElements(by)).thenReturn(List.of(stale)).thenReturn(List.of(fresh)); + + Locator.of(by).click(); + + verify(fresh, times(1)).click(); + } + + @Test + public void click_elementIsStaleWhileWaitingForClickable_reResolves() { + WebElement stale = mock(WebElement.class); + when(stale.isDisplayed()).thenThrow(new StaleElementReferenceException("detached")); + WebElement fresh = liveElement(); + By by = By.id("save"); + when(driver.findElements(by)).thenReturn(List.of(stale)).thenReturn(List.of(fresh)); + + Locator.of(by).click(); + + verify(fresh, times(1)).click(); + } + + @Test + public void click_elementNeverGoesStale_clicksExactlyOnce() { + WebElement el = liveElement(); + By by = By.id("save"); + when(driver.findElements(by)).thenReturn(List.of(el)); + + Locator.of(by).click(); + + verify(el, times(1)).click(); + assertEquals(mockingDetails(driver).getInvocations().stream() + .filter(i -> i.getMethod().getName().equals("findElements")).count(), 1L); + } +}