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 diff --git a/src/main/java/com/seleniumboot/locator/Locator.java b/src/main/java/com/seleniumboot/locator/Locator.java index e670813..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; @@ -347,6 +348,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. * @@ -393,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. * 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..603b1c4 --- /dev/null +++ b/src/test/java/com/seleniumboot/unit/LocatorActionsTest.java @@ -0,0 +1,254 @@ +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.Keys; +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 static final String FOCUS_SCRIPT = "arguments[0].focus();"; + + 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[][] {{"press"}, {"focus"}, {"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(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); + } + + @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 "press" -> locator.press(Keys.ENTER, Keys.ESCAPE); + case "focus" -> locator.focus(); + 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 "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()); + } 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 "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()); + 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); + } +}