Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs-site/docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
2 changes: 2 additions & 0 deletions docs-site/docs/guides/semantic-locators.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
76 changes: 76 additions & 0 deletions src/main/java/com/seleniumboot/locator/Locator.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
*
* <p>Re-resolves on every poll and retries stale or not-yet-present elements
* until {@code timeouts.explicit} elapses, including staleness during the gesture.
*
* <pre>getByText("Edit item").doubleClick();</pre>
*
* @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).
*
* <p>Re-resolves on every poll and retries stale or not-yet-present elements
* until {@code timeouts.explicit} elapses, including staleness during the gesture.
*
* <pre>getByText("Options").rightClick();</pre>
*
* @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.
*
Expand Down Expand Up @@ -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.
*
* <p>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.
*
* <pre>getByLabel("Search").press(Keys.ENTER);</pre>
*
* @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.
*
* <p>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.
*
* <pre>getByLabel("Search").focus();</pre>
*
* @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.
*
Expand Down
254 changes: 254 additions & 0 deletions src/test/java/com/seleniumboot/unit/LocatorActionsTest.java
Original file line number Diff line number Diff line change
@@ -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<DriverManager> driverManagerMock;
private MockedStatic<SeleniumBootContext> 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<Collection<Sequence>> 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<Collection<Sequence>> gestures = ArgumentCaptor.captor();
verify(interactive, times(gestureAttempts)).perform(gestures.capture());
Collection<Sequence> 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<Sequence> gesture) {
List<?> events = (List<?>) gesture.iterator().next().encode().get("actions");
return (Map<?, ?>) events.get(0);
}
}