From bf9234136927b565e46d4e83c6d771bd4c4b00eb Mon Sep 17 00:00:00 2001 From: Mohammad Saddam Hossain Date: Sun, 20 Sep 2026 10:38:52 +0530 Subject: [PATCH] =?UTF-8?q?spike(locator):=20stale-proof=20click=20?= =?UTF-8?q?=E2=80=94=20re-resolve=20on=20every=20poll?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Locator.click() resolved the element once, then waited on that reference. If the page re-rendered it the wait either timed out (stale during the clickable check) or StaleElementReferenceException escaped (stale between check and click). click() now resolves inside the wait, ignores StaleElementReferenceException, and re-resolves each poll. Spike scope: click() only. Other terminal actions unchanged. Co-Authored-By: Claude Sonnet 5 --- .../com/seleniumboot/locator/Locator.java | 17 +++- .../unit/LocatorStaleElementTest.java | 96 +++++++++++++++++++ 2 files changed, 111 insertions(+), 2 deletions(-) create mode 100644 src/test/java/com/seleniumboot/unit/LocatorStaleElementTest.java 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); + } +}