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