diff --git a/src/main/java/com/seleniumboot/locator/Locator.java b/src/main/java/com/seleniumboot/locator/Locator.java index e00a58a..3694ebe 100644 --- a/src/main/java/com/seleniumboot/locator/Locator.java +++ b/src/main/java/com/seleniumboot/locator/Locator.java @@ -40,6 +40,28 @@ @SeleniumBootApi(since = "1.4.0") public final class Locator { + /** Records whether a native drag actually delivered a {@code drop} event to the target. */ + private static final String ARM_DROP_LISTENER_JS = + "var t = arguments[0]; t.__sbDropped = false;" + + "t.__sbDropHandler = function () { t.__sbDropped = true; };" + + "t.addEventListener('drop', t.__sbDropHandler);"; + + private static final String COLLECT_DROP_RESULT_JS = + "var t = arguments[0]; t.removeEventListener('drop', t.__sbDropHandler);" + + "var dropped = t.__sbDropped === true; delete t.__sbDropped; delete t.__sbDropHandler;" + + "return dropped;"; + + /** HTML5 DnD fallback: replays the event sequence with one shared DataTransfer. */ + private static final String SYNTHETIC_DRAG_JS = + "var src = arguments[0], dst = arguments[1], dt = new DataTransfer();" + + "function fire(el, type) {" + + " var r = el.getBoundingClientRect();" + + " el.dispatchEvent(new DragEvent(type, {bubbles: true, cancelable: true, composed: true," + + " dataTransfer: dt, clientX: r.left + r.width / 2, clientY: r.top + r.height / 2}));" + + "}" + + "fire(src, 'dragstart'); fire(dst, 'dragenter'); fire(dst, 'dragover');" + + "fire(dst, 'drop'); fire(src, 'dragend');"; + /** How the base set of candidate elements is derived. */ private enum Kind { CSS_OR_BY, ROLE, TEXT, LABEL, PLACEHOLDER, TESTID, ALT_TEXT, TITLE } @@ -305,6 +327,34 @@ public void jsClick() { }); } + /** + * Drags this element onto {@code target} and drops it there. + * + *

Tries the native {@link Actions#dragAndDrop} gesture first. Pages built on the HTML5 + * drag-and-drop API ({@code draggable="true"} plus {@code dragstart}/{@code drop} listeners) + * often ignore it, so when no {@code drop} event reached the target, the full sequence + * ({@code dragstart, dragenter, dragover, drop, dragend}) is dispatched with a synthetic + * {@code DataTransfer}. Both elements are waited for and re-resolved like every other + * terminal action. + * + *

{@code
+     * Locator.byText("Card A").dragTo(Locator.ofCss("#done-column"));
+     * }
+ * + * @throws LocatorException if either element cannot be resolved within {@code timeouts.explicit} + */ + public void dragTo(Locator target) { + target.whenVisible(dst -> whenVisible(src -> { + JavascriptExecutor js = (JavascriptExecutor) driver(); + js.executeScript(ARM_DROP_LISTENER_JS, dst); + new Actions(driver()).dragAndDrop(src, dst).perform(); + if (!Boolean.TRUE.equals(js.executeScript(COLLECT_DROP_RESULT_JS, dst))) { + js.executeScript(SYNTHETIC_DRAG_JS, src, dst); + } + return null; + })); + } + /** Returns the number of elements currently matching the locator chain (no wait). */ public int count() { return resolveAll().size(); diff --git a/src/test/java/com/seleniumboot/unit/LocatorDragToTest.java b/src/test/java/com/seleniumboot/unit/LocatorDragToTest.java new file mode 100644 index 0000000..2bd605e --- /dev/null +++ b/src/test/java/com/seleniumboot/unit/LocatorDragToTest.java @@ -0,0 +1,106 @@ +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.MockedStatic; +import org.openqa.selenium.By; +import org.openqa.selenium.JavascriptExecutor; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.WebElement; +import org.openqa.selenium.interactions.Interactive; +import org.testng.annotations.AfterMethod; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.contains; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; +import static org.testng.Assert.assertThrows; + +/** {@link Locator#dragTo}: native gesture first, synthetic HTML5 DnD only when no drop landed. */ +public class LocatorDragToTest { + + private WebDriver driver; + private JavascriptExecutor js; + private MockedStatic driverManagerMock; + private MockedStatic contextMock; + private WebElement src; + private WebElement dst; + + @BeforeMethod + public void setup() { + driver = mock(WebDriver.class, withSettings().extraInterfaces(JavascriptExecutor.class, Interactive.class)); + js = (JavascriptExecutor) driver; + driverManagerMock = mockStatic(DriverManager.class); + driverManagerMock.when(DriverManager::getDriver).thenReturn(driver); + + SeleniumBootConfig.Timeouts timeouts = new SeleniumBootConfig.Timeouts(); + timeouts.setExplicit(1); + SeleniumBootConfig config = new SeleniumBootConfig(); + config.setTimeouts(timeouts); + contextMock = mockStatic(SeleniumBootContext.class); + contextMock.when(SeleniumBootContext::getConfig).thenReturn(config); + + src = visible(); + dst = visible(); + when(driver.findElements(By.id("src"))).thenReturn(List.of(src)); + when(driver.findElements(By.id("dst"))).thenReturn(List.of(dst)); + } + + @AfterMethod + public void teardown() { + driverManagerMock.close(); + contextMock.close(); + } + + private static WebElement visible() { + WebElement el = mock(WebElement.class); + when(el.isDisplayed()).thenReturn(true); + when(el.isEnabled()).thenReturn(true); + return el; + } + + @Test + public void dragTo_nativeDropLands_doesNotDispatchSyntheticEvents() { + when(js.executeScript(contains("__sbDropped === true"), any())).thenReturn(true); + + Locator.of(By.id("src")).dragTo(Locator.of(By.id("dst"))); + + verify((Interactive) driver).perform(any()); + verify(js, never()).executeScript(contains("dragstart"), any(), any()); + } + + @Test + public void dragTo_nativeDropIgnored_fallsBackToSyntheticDataTransfer() { + when(js.executeScript(contains("__sbDropped === true"), any())).thenReturn(false); + + Locator.of(By.id("src")).dragTo(Locator.of(By.id("dst"))); + + verify((Interactive) driver).perform(any()); + verify(js).executeScript(contains("dragstart"), eq(src), eq(dst)); + } + + @Test + public void dragTo_targetNeverResolves_throwsLocatorException() { + when(driver.findElements(By.id("missing"))).thenReturn(List.of()); + + assertThrows(LocatorException.class, + () -> Locator.of(By.id("src")).dragTo(Locator.of(By.id("missing")))); + verify((Interactive) driver, never()).perform(any()); + } + + @Test + public void dragTo_sourceNeverResolves_throwsLocatorException() { + when(driver.findElements(By.id("missing"))).thenReturn(List.of()); + + assertThrows(LocatorException.class, + () -> Locator.of(By.id("missing")).dragTo(Locator.of(By.id("dst")))); + verify((Interactive) driver, never()).perform(any()); + } +}