diff --git a/docs-site/docs/changelog.md b/docs-site/docs/changelog.md index a28340d..b601d19 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) + - **AI provider is configurable** — `ai.provider` selects the backend for failure analysis and the run summary: `anthropic` (default, unchanged), `gemini`, or `openai` (any OpenAI-compatible `/chat/completions` endpoint — OpenAI, Azure, Groq, local Ollama via `ai.baseUrl`). `ai.model` now defaults per provider. Existing configs keep working with no change. - **AI run summary (opt-in)** — `ai.runSummary: true` adds one short paragraph to the HTML dashboard summarising the run's failures: which same-cause groups account for most of them, which look like product bugs versus test or environment problems, and what to check first. One API call per run, independent of `ai.failureAnalysis`; only grouped error messages and test names are sent (see the data note in the HTML report docs). Absent when disabled, when nothing failed, or on any error; the suite result is never affected. The text is exported as `aiRunSummary` in the metrics JSON. 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 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. * 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());