From 1c8f7ff72cfb2410f94976a5d202f5999c4a6393 Mon Sep 17 00:00:00 2001 From: Xi Hu Date: Mon, 19 Oct 2026 17:00:05 +1100 Subject: [PATCH 1/5] 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 f8be00d5fa036c0720b74c2030b272323a2a569c Mon Sep 17 00:00:00 2001 From: Xi Hu Date: Mon, 19 Oct 2026 17:08:52 +1100 Subject: [PATCH 2/5] 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); + } +} From 1faf86993528ceec1a7486e893dafc6a20e790b5 Mon Sep 17 00:00:00 2001 From: Danyang Tong Date: Mon, 19 Oct 2026 20:02:39 +1100 Subject: [PATCH 3/5] feat(locator): add press(Keys...) and focus() (#134) --- .../com/seleniumboot/locator/Locator.java | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/src/main/java/com/seleniumboot/locator/Locator.java b/src/main/java/com/seleniumboot/locator/Locator.java index 47bb02d..dc77c00 100644 --- a/src/main/java/com/seleniumboot/locator/Locator.java +++ b/src/main/java/com/seleniumboot/locator/Locator.java @@ -7,6 +7,7 @@ import com.seleniumboot.metrics.ExecutionMetrics; import org.openqa.selenium.By; import org.openqa.selenium.JavascriptExecutor; +import org.openqa.selenium.Keys; import org.openqa.selenium.WebDriver; import org.openqa.selenium.OutputType; import org.openqa.selenium.StaleElementReferenceException; @@ -429,6 +430,45 @@ public void append(String text) { whenVisible(el -> { el.sendKeys(text); return null; }); } + /** + * Waits for the element to be visible, then sends the given keys without clearing its value. + * + *

Keys are sent in order using {@link WebElement#sendKeys(CharSequence...)}. + * Re-resolves on every poll and retries stale or not-yet-present elements + * until {@code timeouts.explicit} elapses, including staleness while sending keys. + * + *

getByLabel("Search").press(Keys.ENTER);
+ * + * @param keys Selenium keys to send to the element + * @throws LocatorException if no element matches within {@code timeouts.explicit} + * @throws TimeoutException if a matching element never becomes visible or remains stale + */ + public void press(Keys... keys) { + whenVisible(el -> { + el.sendKeys(keys); + return null; + }); + } + + /** + * Waits for the element to be visible, then focuses it using JavaScript without clicking it. + * + *

The target must be focusable (for example, an input or an element with {@code tabindex}). + * Re-resolves on every poll and retries stale or not-yet-present elements + * until {@code timeouts.explicit} elapses, including staleness while focusing. + * + *

getByLabel("Search").focus();
+ * + * @throws LocatorException if no element matches within {@code timeouts.explicit} + * @throws TimeoutException if a matching element never becomes visible or remains stale + */ + public void focus() { + whenVisible(el -> { + ((JavascriptExecutor) driver()).executeScript("arguments[0].focus();", el); + return null; + }); + } + /** * Waits for the element to be visible and clears its text or value. * From 8aade774ed2b758aaa4b7f7d5cc587a595de32ee Mon Sep 17 00:00:00 2001 From: Danyang Tong Date: Tue, 20 Oct 2026 08:53:12 +1100 Subject: [PATCH 4/5] test(locator): cover press and focus (#134) --- .../com/seleniumboot/unit/LocatorActionsTest.java | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/test/java/com/seleniumboot/unit/LocatorActionsTest.java b/src/test/java/com/seleniumboot/unit/LocatorActionsTest.java index ffbcf00..603b1c4 100644 --- a/src/test/java/com/seleniumboot/unit/LocatorActionsTest.java +++ b/src/test/java/com/seleniumboot/unit/LocatorActionsTest.java @@ -9,6 +9,7 @@ import org.mockito.MockedStatic; import org.openqa.selenium.By; import org.openqa.selenium.JavascriptExecutor; +import org.openqa.selenium.Keys; import org.openqa.selenium.StaleElementReferenceException; import org.openqa.selenium.TimeoutException; import org.openqa.selenium.WebDriver; @@ -31,6 +32,7 @@ public class LocatorActionsTest { private static final By TARGET = By.id("target"); + private static final String FOCUS_SCRIPT = "arguments[0].focus();"; private WebDriver driver; private JavascriptExecutor js; @@ -62,7 +64,7 @@ public void teardown() { @DataProvider(name = "actions") public Object[][] actions() { - return new Object[][] {{"doubleClick"}, {"rightClick"}}; + return new Object[][] {{"press"}, {"focus"}, {"doubleClick"}, {"rightClick"}}; } @Test(dataProvider = "actions") @@ -165,6 +167,7 @@ public void action_elementNeverVisible_timesOutWithoutActing(String action) { expectThrows(TimeoutException.class, () -> act(action)); + verify(hidden, never()).sendKeys(any(Keys[].class)); verify(js, never()).executeScript(anyString(), any(WebElement.class)); verify(interactive, never()).perform(any()); verify(driver, atLeast(2)).findElements(TARGET); @@ -192,6 +195,8 @@ private static WebElement readyElement() { private void act(String action) { Locator locator = Locator.of(TARGET); switch (action) { + case "press" -> locator.press(Keys.ENTER, Keys.ESCAPE); + case "focus" -> locator.focus(); case "doubleClick" -> locator.doubleClick(); case "rightClick" -> locator.rightClick(); default -> throw new IllegalArgumentException(action); @@ -205,6 +210,8 @@ private static boolean isPointerAction(String action) { private void failActionWithStale(String action, WebElement element, boolean always) { StaleElementReferenceException error = new StaleElementReferenceException("re-rendered during action"); switch (action) { + case "press" -> doThrow(error).when(element).sendKeys(Keys.ENTER, Keys.ESCAPE); + case "focus" -> when(js.executeScript(FOCUS_SCRIPT, element)).thenThrow(error); case "doubleClick", "rightClick" -> { if (always) { doThrow(error).when(interactive).perform(any()); @@ -218,6 +225,8 @@ private void failActionWithStale(String action, WebElement element, boolean alwa private void verifySuccessfulAction(String action, WebElement element, int gestureAttempts) { switch (action) { + case "press" -> verify(element, times(1)).sendKeys(Keys.ENTER, Keys.ESCAPE); + case "focus" -> verify(js, times(1)).executeScript(FOCUS_SCRIPT, element); case "doubleClick", "rightClick" -> { ArgumentCaptor> gestures = ArgumentCaptor.captor(); verify(interactive, times(gestureAttempts)).perform(gestures.capture()); From e433f47eba4b0db51e544ecd279569b83575578d Mon Sep 17 00:00:00 2001 From: Danyang Tong Date: Tue, 20 Oct 2026 12:12:22 +1100 Subject: [PATCH 5/5] docs(locator): document locator actions (#134) --- docs-site/docs/changelog.md | 2 ++ docs-site/docs/guides/semantic-locators.md | 2 ++ 2 files changed, 4 insertions(+) diff --git a/docs-site/docs/changelog.md b/docs-site/docs/changelog.md index 4892aea..ce5e7ab 100644 --- a/docs-site/docs/changelog.md +++ b/docs-site/docs/changelog.md @@ -11,6 +11,8 @@ All notable changes to Selenium Boot are documented here. ## [Unreleased] +- **`Locator` keyboard and pointer actions: `press(Keys...)`, `focus()`, `doubleClick()`, `rightClick()`** — send keys without clearing the value, focus without clicking, or perform native double/context clicks directly on a locator. Each action re-resolves on every poll and retries stale or not-yet-present elements until `timeouts.explicit` elapses. (#134) + - **Failure Intelligence: high-level triage and suggested actions** — each failure is classified as `Product bug`, `Test bug`, `Environment`, `Flaky` or `Unknown` (flaky keeps its cause, e.g. `Flaky (Locator not found)`), with a rule-based suggested action. Shown in the HTML report, as `failureType` / `suggestedAction` in the metrics JSON, and in the JUnit XML; opt-in AI analysis uses it as a baseline. No API key needed. (#138) - **HTML report: tab and failure fixes** — the report always opens on the Dashboard (it no longer restores the last-open tab), failure details start collapsed, and the Failures tab no longer goes empty after you expand or collapse a class in Test Cases (both tabs shared one group id). diff --git a/docs-site/docs/guides/semantic-locators.md b/docs-site/docs/guides/semantic-locators.md index 9f1a52d..673b0f7 100644 --- a/docs-site/docs/guides/semantic-locators.md +++ b/docs-site/docs/guides/semantic-locators.md @@ -24,6 +24,8 @@ Every semantic locator returns the same chainable, **auto-waiting** `Locator` used by the `$()` API — no `Thread.sleep`, no explicit waits. They're available on both `BaseTest` and `BasePage`. Narrow multiple matches using `.first()`, `.last()`, or `.nth(index)` (e.g. `getByRole(Role.LISTITEM).first().click()`). +Use `press(org.openqa.selenium.Keys...)` (e.g. `getByLabel("Search").press(Keys.ENTER)`, with `import org.openqa.selenium.Keys;`) to send keys without clearing, `focus()` to focus a focusable element without clicking, and `doubleClick()` / `rightClick()` for native pointer gestures; all four auto-wait and re-resolve on each poll up to `timeouts.explicit`. + Assertions can retry focus and empty-state checks, and accept regular expressions for text and values: `assertThat(getByLabel("Email")).isFocused()`, `isEmpty()`, or `hasValue(Pattern.compile("user-\\d+"))`. Auto-waiting covers the element *existing* as well as being visible: an action such as `click()` or