From a9e9e87ff01af80e3eb1d9116d8f7280f311d243 Mon Sep 17 00:00:00 2001 From: Xi Hu Date: Mon, 19 Oct 2026 17:00:05 +1100 Subject: [PATCH 1/2] feat(locator): add doubleClick() and rightClick() (#134) BasePage already offers doubleClick/rightClick, but Locator did not, so users had to drop out of the Locator API for these gestures. Both methods perform the gesture with Selenium's Actions API and go through the existing terminal-action helpers, so they follow the same contract as click() and hover() (#67/#68): the element is re-resolved from scratch on every poll, and stale or not-yet-present elements are retried until timeouts.explicit. A StaleElementReferenceException thrown while the gesture is performed is retried as well, rather than being treated as success. - doubleClick() waits for the element to be visible and enabled, like click(). - rightClick() waits for the element to be visible, like hover(). Each method has Javadoc with a usage example. Tests follow in the next commit. Refs #134 --- .../com/seleniumboot/locator/Locator.java | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/src/main/java/com/seleniumboot/locator/Locator.java b/src/main/java/com/seleniumboot/locator/Locator.java index e670813..47bb02d 100644 --- a/src/main/java/com/seleniumboot/locator/Locator.java +++ b/src/main/java/com/seleniumboot/locator/Locator.java @@ -347,6 +347,42 @@ public void click() { whenReady(el -> el.isDisplayed() && el.isEnabled(), el -> { el.click(); return null; }); } + /** + * Waits for the element to be visible and enabled, then double-clicks it. + * + *

Re-resolves on every poll and retries stale or not-yet-present elements + * until {@code timeouts.explicit} elapses, including staleness during the gesture. + * + *

getByText("Edit item").doubleClick();
+ * + * @throws LocatorException if no element matches within {@code timeouts.explicit} + * @throws TimeoutException if a matching element never becomes ready or remains stale + */ + public void doubleClick() { + whenReady(el -> el.isDisplayed() && el.isEnabled(), el -> { + new Actions(driver()).doubleClick(el).perform(); + return null; + }); + } + + /** + * Waits for the element to be visible, then right-clicks it (context click). + * + *

Re-resolves on every poll and retries stale or not-yet-present elements + * until {@code timeouts.explicit} elapses, including staleness during the gesture. + * + *

getByText("Options").rightClick();
+ * + * @throws LocatorException if no element matches within {@code timeouts.explicit} + * @throws TimeoutException if a matching element never becomes visible or remains stale + */ + public void rightClick() { + whenVisible(el -> { + new Actions(driver()).contextClick(el).perform(); + return null; + }); + } + /** * Waits for the element to be visible and enabled, then checks it if it is not already checked. * From 1861f2889c73c75e53b1dba67fb42769657d966c Mon Sep 17 00:00:00 2001 From: Xi Hu Date: Mon, 19 Oct 2026 17:08:52 +1100 Subject: [PATCH 2/2] test(locator): pointer actions follow the terminal-action contract (#134) Add LocatorActionsTest, which checks that doubleClick() and rightClick() honour the same terminal-action contract as click() (#67/#68). The tests mock the driver as Interactive so the Actions sequences can be captured, and set timeouts.explicit to 1s to keep the timeout cases fast. Each pointer action is run through a shared data provider covering: - a ready element: the gesture is performed once on one lookup, and the element is never cleared or plain-clicked - an element that appears later: the locator re-resolves first - an element that is hidden at first: the action waits for it to become visible - an element that goes stale during the readiness check: a fresh element is resolved and used - an element that goes stale during the gesture: the gesture is retried on a freshly resolved element - an element that never appears: LocatorException with a TimeoutException cause, and no gesture is performed - an element that stays stale during the gesture: the action times out instead of reporting success - an element that never becomes visible: times out without acting A separate test checks that doubleClick() waits until the element is enabled. The successful cases also decode the captured gesture to check the target element, the number of press/release pairs (2 for doubleClick, 1 for rightClick) and the mouse button (left for doubleClick, right for rightClick). The data provider is shared so that the keyboard and focus actions can be added to the same matrix. Refs #134 --- .../seleniumboot/unit/LocatorActionsTest.java | 245 ++++++++++++++++++ 1 file changed, 245 insertions(+) create mode 100644 src/test/java/com/seleniumboot/unit/LocatorActionsTest.java diff --git a/src/test/java/com/seleniumboot/unit/LocatorActionsTest.java b/src/test/java/com/seleniumboot/unit/LocatorActionsTest.java new file mode 100644 index 0000000..ffbcf00 --- /dev/null +++ b/src/test/java/com/seleniumboot/unit/LocatorActionsTest.java @@ -0,0 +1,245 @@ +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 com.seleniumboot.locator.LocatorException; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; +import org.openqa.selenium.By; +import org.openqa.selenium.JavascriptExecutor; +import org.openqa.selenium.StaleElementReferenceException; +import org.openqa.selenium.TimeoutException; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.WebElement; +import org.openqa.selenium.interactions.Interactive; +import org.openqa.selenium.interactions.Sequence; +import org.testng.annotations.AfterMethod; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.DataProvider; +import org.testng.annotations.Test; + +import java.util.Collection; +import java.util.List; +import java.util.Map; + +import static org.mockito.Mockito.*; +import static org.testng.Assert.*; + +/** Public keyboard, focus and pointer actions share the existing terminal-action contract. */ +public class LocatorActionsTest { + + private static final By TARGET = By.id("target"); + + private WebDriver driver; + private JavascriptExecutor js; + private Interactive interactive; + private MockedStatic driverManagerMock; + private MockedStatic contextMock; + + @BeforeMethod + public void setup() { + driver = mock(WebDriver.class, withSettings().extraInterfaces(JavascriptExecutor.class, Interactive.class)); + js = (JavascriptExecutor) driver; + interactive = (Interactive) driver; + driverManagerMock = mockStatic(DriverManager.class); + driverManagerMock.when(DriverManager::getDriver).thenReturn(driver); + + SeleniumBootConfig config = new SeleniumBootConfig(); + SeleniumBootConfig.Timeouts timeouts = new SeleniumBootConfig.Timeouts(); + timeouts.setExplicit(1); + config.setTimeouts(timeouts); + contextMock = mockStatic(SeleniumBootContext.class); + contextMock.when(SeleniumBootContext::getConfig).thenReturn(config); + } + + @AfterMethod + public void teardown() { + driverManagerMock.close(); + contextMock.close(); + } + + @DataProvider(name = "actions") + public Object[][] actions() { + return new Object[][] {{"doubleClick"}, {"rightClick"}}; + } + + @Test(dataProvider = "actions") + public void action_readyElement_performsRequestedActionOnce(String action) { + WebElement element = readyElement(); + when(driver.findElements(TARGET)).thenReturn(List.of(element)); + + act(action); + + verifySuccessfulAction(action, element, 1); + verify(driver, times(1)).findElements(TARGET); + verify(element, never()).clear(); + verify(element, never()).click(); + } + + @Test(dataProvider = "actions") + public void action_elementAppearsLater_reResolvesBeforeActing(String action) { + WebElement element = readyElement(); + when(driver.findElements(TARGET)).thenReturn(List.of()).thenReturn(List.of(element)); + + act(action); + + verifySuccessfulAction(action, element, 1); + verify(driver, times(2)).findElements(TARGET); + } + + @Test(dataProvider = "actions") + public void action_elementInitiallyHidden_waitsForVisibility(String action) { + WebElement element = readyElement(); + when(element.isDisplayed()).thenReturn(false).thenReturn(true); + when(driver.findElements(TARGET)).thenReturn(List.of(element)); + + act(action); + + verifySuccessfulAction(action, element, 1); + verify(driver, times(2)).findElements(TARGET); + } + + @Test(dataProvider = "actions") + public void action_elementStaleDuringReadiness_reResolvesFreshElement(String action) { + WebElement stale = readyElement(); + when(stale.isDisplayed()).thenThrow(new StaleElementReferenceException("re-rendered")); + WebElement fresh = readyElement(); + when(driver.findElements(TARGET)).thenReturn(List.of(stale)).thenReturn(List.of(fresh)); + + act(action); + + verifySuccessfulAction(action, fresh, 1); + verify(driver, times(2)).findElements(TARGET); + } + + @Test(dataProvider = "actions") + public void action_elementStaleDuringAction_retriesActionOnFreshElement(String action) { + WebElement stale = readyElement(); + WebElement fresh = readyElement(); + when(driver.findElements(TARGET)).thenReturn(List.of(stale)).thenReturn(List.of(fresh)); + failActionWithStale(action, stale, false); + + act(action); + + verifySuccessfulAction(action, fresh, isPointerAction(action) ? 2 : 1); + verify(driver, times(2)).findElements(TARGET); + if (isPointerAction(action)) { + ArgumentCaptor> gestures = ArgumentCaptor.captor(); + verify(interactive, times(2)).perform(gestures.capture()); + assertSame(pointerMove(gestures.getAllValues().get(0)).get("origin"), stale); + } + } + + @Test(dataProvider = "actions") + public void action_elementNeverAppears_throwsLocatorExceptionWithTimeoutCause(String action) { + when(driver.findElements(TARGET)).thenReturn(List.of()); + + LocatorException error = expectThrows(LocatorException.class, () -> act(action)); + + assertTrue(error.getCause() instanceof TimeoutException); + assertTrue(error.getMessage().contains("waited 1s"), error.getMessage()); + verify(driver, atLeast(2)).findElements(TARGET); + verify(js, never()).executeScript(anyString(), any(WebElement.class)); + verify(interactive, never()).perform(any()); + } + + @Test(dataProvider = "actions") + public void action_elementAlwaysStaleDuringAction_timesOutInsteadOfReportingSuccess(String action) { + WebElement stale = readyElement(); + when(driver.findElements(TARGET)).thenReturn(List.of(stale)); + failActionWithStale(action, stale, true); + + TimeoutException error = expectThrows(TimeoutException.class, () -> act(action)); + + assertTrue(error.getCause() instanceof StaleElementReferenceException); + verify(driver, atLeast(2)).findElements(TARGET); + } + + @Test(dataProvider = "actions") + public void action_elementNeverVisible_timesOutWithoutActing(String action) { + WebElement hidden = readyElement(); + when(hidden.isDisplayed()).thenReturn(false); + when(driver.findElements(TARGET)).thenReturn(List.of(hidden)); + + expectThrows(TimeoutException.class, () -> act(action)); + + verify(js, never()).executeScript(anyString(), any(WebElement.class)); + verify(interactive, never()).perform(any()); + verify(driver, atLeast(2)).findElements(TARGET); + } + + @Test + public void doubleClick_elementInitiallyDisabled_waitsUntilEnabled() { + WebElement element = readyElement(); + when(element.isEnabled()).thenReturn(false).thenReturn(true); + when(driver.findElements(TARGET)).thenReturn(List.of(element)); + + Locator.of(TARGET).doubleClick(); + + verifySuccessfulAction("doubleClick", element, 1); + verify(driver, times(2)).findElements(TARGET); + } + + private static WebElement readyElement() { + WebElement element = mock(WebElement.class); + when(element.isDisplayed()).thenReturn(true); + when(element.isEnabled()).thenReturn(true); + return element; + } + + private void act(String action) { + Locator locator = Locator.of(TARGET); + switch (action) { + case "doubleClick" -> locator.doubleClick(); + case "rightClick" -> locator.rightClick(); + default -> throw new IllegalArgumentException(action); + } + } + + private static boolean isPointerAction(String action) { + return action.equals("doubleClick") || action.equals("rightClick"); + } + + private void failActionWithStale(String action, WebElement element, boolean always) { + StaleElementReferenceException error = new StaleElementReferenceException("re-rendered during action"); + switch (action) { + case "doubleClick", "rightClick" -> { + if (always) { + doThrow(error).when(interactive).perform(any()); + } else { + doThrow(error).doNothing().when(interactive).perform(any()); + } + } + default -> throw new IllegalArgumentException(action); + } + } + + private void verifySuccessfulAction(String action, WebElement element, int gestureAttempts) { + switch (action) { + case "doubleClick", "rightClick" -> { + ArgumentCaptor> gestures = ArgumentCaptor.captor(); + verify(interactive, times(gestureAttempts)).perform(gestures.capture()); + Collection last = gestures.getValue(); + assertSame(pointerMove(last).get("origin"), element); + List events = (List) last.iterator().next().encode().get("actions"); + int expectedClicks = action.equals("doubleClick") ? 2 : 1; + int expectedButton = action.equals("doubleClick") ? 0 : 2; + assertEquals(events.stream().filter(e -> "pointerDown".equals(((Map) e).get("type"))).count(), + (long) expectedClicks); + assertEquals(events.stream().filter(e -> "pointerUp".equals(((Map) e).get("type"))).count(), + (long) expectedClicks); + events.stream().map(e -> (Map) e) + .filter(e -> "pointerDown".equals(e.get("type")) || "pointerUp".equals(e.get("type"))) + .forEach(e -> assertEquals(e.get("button"), expectedButton)); + } + default -> throw new IllegalArgumentException(action); + } + } + + private static Map pointerMove(Collection gesture) { + List events = (List) gesture.iterator().next().encode().get("actions"); + return (Map) events.get(0); + } +}