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.
*
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);
+ }
+}