Skip to content
Merged
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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/).

- **`Locator.first()` and `Locator.last()`** — narrow matches to the first or last matching element (e.g. `getByRole(Role.LISTITEM).first()`). Both resolve at action time, re-evaluating candidate elements on every poll and retrying stale/not-yet-present elements up to `timeouts.explicit`. (#132)

- **`Locator` form actions: `clear()`, `check()`, `uncheck()`, `isChecked()`, `selectOption(text)`, `selectOptionByValue(value)`, `inputValue()`** — form controls can now be manipulated and inspected directly on `Locator` without falling back to `BasePage` helpers or raw `WebElement`s. `check()` and `uncheck()` are idempotent (no click if already in the target state), `isChecked()` queries checked state without waiting, and terminal actions (`clear`, `check`, `uncheck`, `selectOption`, `selectOptionByValue`, `inputValue`) auto-wait, re-resolving from scratch on every poll to survive stale or late-appearing elements. (#133)

- **HTML report: the "Avg Time (ms)" stat tile is now "Suite Time"** — the wall-clock suite duration in `hh:mm:ss`, matching the sidebar.

- **HTML report: suite duration in `hh:mm:ss`, wall-clock.** The report previously showed the *sum* of every test's time in milliseconds, which overstates a parallel run. It now shows how long the suite actually ran (`01:42:07`; hours aren't capped). The metrics JSON gains `suiteDurationMs`; `totalTimeMs` (summed test time) is unchanged.
Expand Down
4 changes: 4 additions & 0 deletions docs-site/docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ sidebar_position: 99

All notable changes to Selenium Boot are documented here.

## [Unreleased]

- **`Locator` form actions: `clear()`, `check()`, `uncheck()`, `isChecked()`, `selectOption(text)`, `selectOptionByValue(value)`, `inputValue()`** — form controls can now be manipulated and inspected directly on `Locator` without falling back to `BasePage` helpers or raw `WebElement`s. `check()` and `uncheck()` are idempotent (no click if already in the target state), `isChecked()` queries checked state without waiting, and terminal actions (`clear`, `check`, `uncheck`, `selectOption`, `selectOptionByValue`, `inputValue`) auto-wait, re-resolving from scratch on every poll to survive stale or late-appearing elements. (#133)

---

## [3.8.0] — 2026-10-11
Expand Down
4 changes: 2 additions & 2 deletions docs-site/docs/guides/semantic-locators.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ on both `BaseTest` and `BasePage`. Narrow multiple matches using `.first()`, `.l
Auto-waiting covers the element *existing* as well as being visible: an action such as `click()` or
`type()` keeps looking for the element until `timeouts.explicit` elapses, and looks it up afresh each
time, so a page that re-renders it mid-action doesn't cause a stale-element error. If it never
appears you get a `LocatorException` after the timeout. `isVisible()`, `isHidden()`, `isEnabled()`
and `count()` never wait.
appears you get a `LocatorException` after the timeout. `isVisible()`, `isHidden()`, `isEnabled()`,
`isChecked()` and `count()` never wait.

---

Expand Down
111 changes: 111 additions & 0 deletions src/main/java/com/seleniumboot/locator/Locator.java
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import org.openqa.selenium.TimeoutException;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.interactions.Actions;
import org.openqa.selenium.support.ui.Select;
import org.openqa.selenium.support.ui.WebDriverWait;

import java.time.Duration;
Expand Down Expand Up @@ -297,6 +298,38 @@ public void click() {
whenReady(el -> el.isDisplayed() && el.isEnabled(), el -> { el.click(); return null; });
}

/**
* Waits for the element to be visible and enabled, then checks it if it is not already checked.
*
* <p>Idempotent: does not click if the element is already checked. Works on checkboxes,
* radio buttons, and elements with {@code role="checkbox"}. Re-resolves on every poll
* and retries stale or not-yet-present elements until {@code timeouts.explicit} elapses.
*/
public void check() {
whenReady(el -> el.isDisplayed() && el.isEnabled(), el -> {
if (!isSelected(el)) {
el.click();
}
return null;
});
}

/**
* Waits for the element to be visible and enabled, then unchecks it if it is currently checked.
*
* <p>Idempotent: does not click if the element is already unchecked. Works on checkboxes
* and elements with {@code role="checkbox"}. Re-resolves on every poll and retries stale
* or not-yet-present elements until {@code timeouts.explicit} elapses.
*/
public void uncheck() {
whenReady(el -> el.isDisplayed() && el.isEnabled(), el -> {
if (isSelected(el)) {
el.click();
}
return null;
});
}

/** Waits for the element to be visible, clears it, then types the given text. */
public void type(String text) {
whenVisible(el -> {
Expand All @@ -311,6 +344,19 @@ public void append(String text) {
whenVisible(el -> { el.sendKeys(text); return null; });
}

/**
* Waits for the element to be visible and clears its text or value.
*
* <p>Re-resolves on every poll and retries stale or not-yet-present elements
* until {@code timeouts.explicit} elapses.
*/
public void clear() {
whenVisible(el -> {
el.clear();
return null;
});
}

/** Waits for the element to be visible and returns its trimmed visible text. */
public String getText() {
return whenVisible(el -> el.getText().trim());
Expand All @@ -321,6 +367,25 @@ public String getAttribute(String name) {
return whenVisible(el -> el.getAttribute(name));
}

/**
* Waits for the element to be visible and returns the current value of an {@code <input>},
* {@code <textarea>}, or other form control.
*
* <p>Retrieves the element's DOM {@code value} property, falling back to the {@code value}
* attribute. Returns an empty string if no value is present.
*
* @return the input's current value, or {@code ""} if none
*/
public String inputValue() {
return whenVisible(el -> {
String val = el.getDomProperty("value");
if (val == null) {
val = el.getAttribute("value");
}
return val != null ? val : "";
});
}

/** Returns true if the element is present and displayed — does NOT wait. */
public boolean isVisible() {
try {
Expand All @@ -344,6 +409,18 @@ public boolean isEnabled() {
}
}

/**
* Returns true if the element (checkbox, radio button, or ARIA checkable control)
* is present and checked — does NOT wait.
*/
public boolean isChecked() {
try {
return isSelected(resolve());
} catch (Exception e) {
return false;
}
}

/** Hovers over the element using Actions. */
public void hover() {
whenVisible(el -> { new Actions(driver()).moveToElement(el).perform(); return null; });
Expand Down Expand Up @@ -393,6 +470,36 @@ public void dragTo(Locator target) {
}));
}

/**
* Selects an option in a {@code <select>} element by its visible text.
*
* <p>Waits for the element to be visible, and re-resolves from scratch on every poll
* until {@code timeouts.explicit} elapses.
*
* @param text visible text of the option to select
*/
public void selectOption(String text) {
whenVisible(el -> {
new Select(el).selectByVisibleText(text);
return null;
});
}

/**
* Selects an option in a {@code <select>} element by its {@code value} attribute.
*
* <p>Waits for the element to be visible, and re-resolves from scratch on every poll
* until {@code timeouts.explicit} elapses.
*
* @param value value attribute of the option to select
*/
public void selectOptionByValue(String value) {
whenVisible(el -> {
new Select(el).selectByValue(value);
return null;
});
}

/** Returns the number of elements currently matching the locator chain (no wait). */
public int count() {
return resolveAll().size();
Expand Down Expand Up @@ -818,6 +925,10 @@ private static void slowMo(long ms) {
// Helpers
// ------------------------------------------------------------------

private static boolean isSelected(WebElement el) {
return el.isSelected() || "true".equalsIgnoreCase(el.getAttribute("aria-checked"));
}

private WebDriver driver() {
return DriverManager.getDriver();
}
Expand Down
114 changes: 114 additions & 0 deletions src/test/java/com/seleniumboot/unit/LocatorStaleElementTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ private static WebElement liveElement() {
WebElement el = mock(WebElement.class);
when(el.isDisplayed()).thenReturn(true);
when(el.isEnabled()).thenReturn(true);
when(el.getCssValue(anyString())).thenReturn("block");
return el;
}

Expand Down Expand Up @@ -191,6 +192,119 @@ public void jsClick_elementGoesStale_reResolves() {
verify(js).executeScript(anyString(), eq(fresh));
}

@Test
public void clear_elementGoesStale_reResolvesAndClearsFreshElement() {
WebElement stale = liveElement();
doThrow(new StaleElementReferenceException("re-rendered")).when(stale).clear();
WebElement fresh = liveElement();
By by = By.id("name");
when(driver.findElements(by)).thenReturn(List.of(stale)).thenReturn(List.of(fresh));

Locator.of(by).clear();

verify(fresh).clear();
}

@Test
public void clear_elementNeverAppears_throwsLocatorExceptionCausedByTimeout() {
By by = By.id("never");
when(driver.findElements(by)).thenReturn(List.of());

try {
Locator.of(by).clear();
org.testng.Assert.fail("expected LocatorException");
} catch (com.seleniumboot.locator.LocatorException e) {
org.testng.Assert.assertTrue(e.getMessage().contains("No element found"), e.getMessage());
org.testng.Assert.assertTrue(e.getMessage().contains("waited 2s"), e.getMessage());
org.testng.Assert.assertTrue(e.getCause() instanceof org.openqa.selenium.TimeoutException);
}
}

@Test
public void check_elementGoesStale_reResolvesAndChecksFreshElement() {
WebElement stale = liveElement();
when(stale.isSelected()).thenReturn(false);
doThrow(new StaleElementReferenceException("re-rendered")).when(stale).click();
WebElement fresh = liveElement();
when(fresh.isSelected()).thenReturn(false);
By by = By.id("agree");
when(driver.findElements(by)).thenReturn(List.of(stale)).thenReturn(List.of(fresh));

Locator.of(by).check();

verify(fresh).click();
}

@Test
public void uncheck_elementGoesStale_reResolvesAndUnchecksFreshElement() {
WebElement stale = liveElement();
when(stale.isSelected()).thenReturn(true);
doThrow(new StaleElementReferenceException("re-rendered")).when(stale).click();
WebElement fresh = liveElement();
when(fresh.isSelected()).thenReturn(true);
By by = By.id("newsletter");
when(driver.findElements(by)).thenReturn(List.of(stale)).thenReturn(List.of(fresh));

Locator.of(by).uncheck();

verify(fresh).click();
}

@Test
public void selectOption_elementGoesStale_reResolves() {
WebElement staleSelect = liveElement();
when(staleSelect.getTagName()).thenReturn("select");
when(staleSelect.findElements(any(By.class)))
.thenThrow(new StaleElementReferenceException("re-rendered"));

WebElement freshSelect = liveElement();
when(freshSelect.getTagName()).thenReturn("select");
WebElement opt = liveElement();
when(freshSelect.findElements(By.xpath(".//option[normalize-space(.) = \"UK\"]")))
.thenReturn(List.of(opt));

By by = By.id("country");
when(driver.findElements(by)).thenReturn(List.of(staleSelect)).thenReturn(List.of(freshSelect));

Locator.of(by).selectOption("UK");

verify(opt, times(1)).click();
}

@Test
public void selectOptionByValue_elementGoesStale_reResolves() {
WebElement staleSelect = liveElement();
when(staleSelect.getTagName()).thenReturn("select");
when(staleSelect.findElements(any(By.class)))
.thenThrow(new StaleElementReferenceException("re-rendered"));

WebElement freshSelect = liveElement();
when(freshSelect.getTagName()).thenReturn("select");
WebElement opt = liveElement();
when(freshSelect.findElements(By.xpath(".//option[@value = \"uk\"]")))
.thenReturn(List.of(opt));

By by = By.id("country");
when(driver.findElements(by)).thenReturn(List.of(staleSelect)).thenReturn(List.of(freshSelect));

Locator.of(by).selectOptionByValue("uk");

verify(opt, times(1)).click();
}

@Test
public void inputValue_elementGoesStale_reResolves() {
WebElement stale = liveElement();
when(stale.getDomProperty("value"))
.thenThrow(new StaleElementReferenceException("re-rendered"));
WebElement fresh = liveElement();
when(fresh.getDomProperty("value")).thenReturn("admin");
By by = By.id("user");
when(driver.findElements(by)).thenReturn(List.of(stale)).thenReturn(List.of(fresh));

assertEquals(Locator.of(by).inputValue(), "admin");
}

@Test
public void last_elementGoesStaleBetweenCheckAndClick_reResolvesAndClicksFreshLastElement() {
WebElement other = liveElement();
Expand Down
Loading
Loading