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 @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/).

### Unreleased

- **`Locator`: `waitFor(State)`, `allTextContents()`, and element `screenshot()`** — `waitFor(State)` (and `waitFor()`) waits for an element to reach a target lifecycle state (`VISIBLE`, `HIDDEN`, `ATTACHED`, `DETACHED`), retrying up to `timeouts.explicit`. `allTextContents()` returns the text content of all matched elements in a single pass without waiting if empty, re-resolving if an element goes stale during read. `screenshot()` returns the element's PNG bytes, and `screenshot(Path)` writes them to a file path; both terminal actions re-resolve on every poll to survive stale elements. (#135)

- **`flakiness.historyDir` / `-Dseleniumboot.history.dir`** — metrics history can now live outside `target/`. Builds that clean `target/` or discard the CI workspace between runs had no history, so the Flakiness Radar and the newly/consistently-failing and intermittent statuses stayed empty. Point it at a directory your CI restores and saves (a cache or artifact store); the system property wins over the config. Default unchanged: `<reports dir>/metrics-history`. (#141)

- **Retry- and history-aware test status** — each test now gets a status from this run's retries and the earlier runs in `target/metrics-history/`: **Passed on retry**, **Newly failing** (passed in every earlier run in the window), **Consistently failing** (failed in the two or more earlier runs) or **Intermittent** (mixed earlier results). A "Test status" card sits next to the Flakiness Radar, the status shows in the failure detail, and newly failing tests are listed first in the Failures tab. The metrics JSON gains `testStatus`. Radar thresholds and `failOnHighFlakiness` are unchanged. (#141)
Expand Down
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`: `waitFor(State)`, `allTextContents()`, and element `screenshot()`** — `waitFor(State)` (and `waitFor()`) waits for an element to reach a target lifecycle state (`VISIBLE`, `HIDDEN`, `ATTACHED`, `DETACHED`), retrying up to `timeouts.explicit`. `allTextContents()` returns the text content of all matched elements in a single pass without waiting if empty, re-resolving if an element goes stale during read. `screenshot()` returns the element's PNG bytes, and `screenshot(Path)` writes them to a file path; both terminal actions re-resolve on every poll to survive stale elements. (#135)

- **`flakiness.historyDir` / `-Dseleniumboot.history.dir`** — metrics history can now live outside `target/`. Builds that clean `target/` or discard the CI workspace between runs had no history, so the Flakiness Radar and the newly/consistently-failing and intermittent statuses stayed empty. Point it at a directory your CI restores and saves (a cache or artifact store); the system property wins over the config. Default unchanged: `<reports dir>/metrics-history`. (#141)

- **Retry- and history-aware test status** — each test now gets a status from this run's retries and the earlier runs in `target/metrics-history/`: **Passed on retry**, **Newly failing** (passed in every earlier run in the window), **Consistently failing** (failed in the two or more earlier runs) or **Intermittent** (mixed earlier results). A "Test status" card sits next to the Flakiness Radar, the status shows in the failure detail, and newly failing tests are listed first in the Failures tab. The metrics JSON gains `testStatus`. Radar thresholds and `failOnHighFlakiness` are unchanged. (#141)
Expand Down
4 changes: 3 additions & 1 deletion docs-site/docs/guides/semantic-locators.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@ Auto-waiting covers the element *existing* as well as being visible: an action s
`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()`,
`isChecked()` and `count()` never wait.
`isChecked()`, `count()`, and `allTextContents()` never wait. Use `waitFor(State)` (`VISIBLE`, `HIDDEN`,
`ATTACHED`, `DETACHED`) to wait explicitly for a lifecycle state, and `screenshot()` / `screenshot(path)`
to capture element PNG bytes or files.

---

Expand Down
180 changes: 180 additions & 0 deletions src/main/java/com/seleniumboot/locator/Locator.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,20 @@
import org.openqa.selenium.By;
import org.openqa.selenium.JavascriptExecutor;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.OutputType;
import org.openqa.selenium.StaleElementReferenceException;
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.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
Expand Down Expand Up @@ -286,6 +291,49 @@ public By toBy() {
// Terminal actions — all auto-wait
// ------------------------------------------------------------------

/**
* Waits for the element to reach the specified {@link State}.
*
* <p>Re-resolves from scratch on every poll and retries stale or not-yet-present
* elements until {@code timeouts.explicit} elapses.
*
* <pre>
* $("#spinner").waitFor(State.DETACHED);
* $(".toast").waitFor(State.VISIBLE);
* </pre>
*
* @param state target element lifecycle state (visible, hidden, attached, detached)
* @throws LocatorException if the element does not reach {@code state} within {@code timeouts.explicit}
*/
public void waitFor(State state) {
if (state == null) {
state = State.VISIBLE;
}
SeleniumBootConfig cfg = SeleniumBootContext.getConfig();
int timeout = cfg.getTimeouts().getExplicit();
final State targetState = state;
try {
new WebDriverWait(driver(), Duration.ofSeconds(timeout))
.ignoring(StaleElementReferenceException.class, LocatorException.class)
.until(d -> checkState(targetState));
} catch (TimeoutException e) {
throw new LocatorException("Element did not reach state " + targetState + ": " + describe()
+ " (waited " + timeout + "s)", e);
}
if (cfg.getDebug().getSlowMoMs() > 0) slowMo(cfg.getDebug().getSlowMoMs());
}

/**
* Waits for the element to be visible.
*
* <p>Equivalent to {@code waitFor(State.VISIBLE)}.
*
* @throws LocatorException if the element does not become visible within {@code timeouts.explicit}
*/
public void waitFor() {
waitFor(State.VISIBLE);
}

/**
* Waits for the element to exist and be clickable, then clicks it.
*
Expand Down Expand Up @@ -500,11 +548,94 @@ public void selectOptionByValue(String value) {
});
}

/**
* Captures a PNG screenshot of the resolved element and returns its bytes.
*
* <p>Waits for the element to be visible, re-resolving from scratch on every poll
* and retrying stale or not-yet-present elements until {@code timeouts.explicit} elapses.
*
* @return PNG image bytes of the element
* @throws LocatorException if the element does not become visible within {@code timeouts.explicit}
*/
public byte[] screenshot() {
return whenVisible(el -> el.getScreenshotAs(OutputType.BYTES));
}

/**
* Captures a PNG screenshot of the resolved element and writes it to the specified file path.
*
* <p>Creates parent directories if they do not exist. Waits for the element to be visible,
* re-resolving from scratch on every poll until {@code timeouts.explicit} elapses.
*
* @param path destination path for the PNG file
* @throws LocatorException if the element cannot be resolved or if writing to the path fails
*/
public void screenshot(Path path) {
if (path == null) {
throw new IllegalArgumentException("Screenshot path cannot be null");
}
byte[] bytes = screenshot();
try {
Path parent = path.getParent();
if (parent != null) {
Files.createDirectories(parent);
}
Files.write(path, bytes);
} catch (IOException e) {
throw new LocatorException("Failed to write screenshot to '" + path + "': " + e.getMessage(), e);
}
}

/** Returns the number of elements currently matching the locator chain (no wait). */
public int count() {
return resolveAll().size();
}

/**
* Returns the text content of all elements matching this locator chain, without waiting for
* elements to appear.
*
* <p>Returns an empty list immediately if no elements match. Returns each element's rendered
* text ({@link WebElement#getText()}), falling back to its DOM {@code textContent} property
* for hidden elements whose rendered text is blank. If an element goes stale while its text
* is being read, the list is re-resolved from scratch until {@code timeouts.explicit} elapses.
*
* @return list of text contents of all matched elements
*/
public List<String> allTextContents() {
SeleniumBootConfig cfg = SeleniumBootContext.getConfig();
int timeout = cfg.getTimeouts().getExplicit();
AtomicReference<List<String>> result = new AtomicReference<>();
new WebDriverWait(driver(), Duration.ofSeconds(timeout))
.ignoring(StaleElementReferenceException.class)
.until(d -> {
try {
List<WebElement> candidates = resolveAll();
if (candidates.isEmpty()) {
result.set(Collections.emptyList());
return Boolean.TRUE;
}
List<String> texts = new ArrayList<>(candidates.size());
for (WebElement el : candidates) {
String text = el.getText();
if (text == null || text.isBlank()) {
String dom = el.getDomProperty("textContent");
if (dom != null && !dom.isBlank()) {
text = dom;
}
}
texts.add(text != null ? text.trim() : "");
}
result.set(texts);
return Boolean.TRUE;
} catch (LocatorException e) {
result.set(Collections.emptyList());
return Boolean.TRUE;
}
});
return result.get();
}

/** Returns the resolved {@link WebElement}, applying all chain filters. */
public WebElement element() {
return whenVisible(el -> el);
Expand Down Expand Up @@ -863,6 +994,55 @@ private boolean nameMatches(WebDriver d, WebElement el, String expected) {
// Wait helpers
// ------------------------------------------------------------------

private Boolean checkState(State state) {
switch (state) {
case VISIBLE: {
try {
WebElement el = resolve();
if (!el.isDisplayed()) return null;
SeleniumBootConfig.Debug debug = SeleniumBootContext.getConfig().getDebug();
if (debug.isHighlight()) highlight(el);
return Boolean.TRUE;
} catch (LocatorException | StaleElementReferenceException e) {
return null;
}
}
case HIDDEN: {
try {
WebElement el = resolve();
return !el.isDisplayed() ? Boolean.TRUE : null;
} catch (LocatorException e) {
return Boolean.TRUE;
} catch (StaleElementReferenceException e) {
return null;
}
}
case ATTACHED: {
try {
WebElement el = resolve();
el.isEnabled();
SeleniumBootConfig.Debug debug = SeleniumBootContext.getConfig().getDebug();
if (debug.isHighlight()) highlight(el);
return Boolean.TRUE;
} catch (LocatorException | StaleElementReferenceException e) {
return null;
}
}
case DETACHED: {
try {
resolve();
return null;
} catch (LocatorException e) {
return Boolean.TRUE;
} catch (StaleElementReferenceException e) {
return null;
}
}
default:
throw new IllegalArgumentException("Unsupported state: " + state);
}
}

private <T> T whenVisible(Function<WebElement, T> action) {
return whenReady(WebElement::isDisplayed, action);
}
Expand Down
41 changes: 41 additions & 0 deletions src/main/java/com/seleniumboot/locator/State.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package com.seleniumboot.locator;

import com.seleniumboot.api.SeleniumBootApi;

/**
* Element lifecycle states for {@link Locator#waitFor(State)}.
*
* <p>Mirrors Playwright's {@code locator.waitFor({ state })}:
* <ul>
* <li>{@link #VISIBLE} — element is attached to the DOM and displayed.</li>
* <li>{@link #HIDDEN} — element is either detached from the DOM or not displayed.</li>
* <li>{@link #ATTACHED} — element is present in the DOM (regardless of visibility).</li>
* <li>{@link #DETACHED} — element is not present in the DOM.</li>
* </ul>
*
* @since 3.9.0
*/
@SeleniumBootApi(since = "3.9.0")
public enum State {

/**
* Element is present in the DOM and displayed to the user.
*/
VISIBLE,

/**
* Element is either detached from the DOM or hidden (e.g. {@code display: none},
* {@code visibility: hidden}, or opacity 0).
*/
HIDDEN,

/**
* Element is present in the DOM, regardless of its visibility.
*/
ATTACHED,

/**
* Element is not present in the DOM.
*/
DETACHED
}
79 changes: 79 additions & 0 deletions src/test/java/com/seleniumboot/unit/LocatorStaleElementTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@
import com.seleniumboot.driver.DriverManager;
import com.seleniumboot.internal.SeleniumBootContext;
import com.seleniumboot.locator.Locator;
import com.seleniumboot.locator.LocatorException;
import com.seleniumboot.locator.State;
import org.mockito.MockedStatic;
import org.openqa.selenium.By;
import org.openqa.selenium.OutputType;
import org.openqa.selenium.StaleElementReferenceException;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebElement;
Expand Down Expand Up @@ -353,4 +356,80 @@ public void first_elementGoesStaleBetweenCheckAndClick_reResolvesAndClicksFreshF
verify(freshFirst, times(1)).click();
verify(staleFirst, times(1)).click();
}

@Test
public void waitFor_visible_elementGoesStale_reResolves() {
WebElement stale = mock(WebElement.class);
when(stale.isDisplayed()).thenThrow(new StaleElementReferenceException("detached"));
WebElement fresh = liveElement();
By by = By.id("status");
when(driver.findElements(by)).thenReturn(List.of(stale)).thenReturn(List.of(fresh));

Locator.of(by).waitFor(State.VISIBLE);

verify(fresh, atLeastOnce()).isDisplayed();
}

@Test
public void waitFor_hidden_elementGoesStaleWhileChecking_reResolves() {
WebElement stale = mock(WebElement.class);
when(stale.isDisplayed()).thenThrow(new StaleElementReferenceException("detached"));
By by = By.id("loader");
when(driver.findElements(by)).thenReturn(List.of(stale)).thenReturn(List.of());

Locator.of(by).waitFor(State.HIDDEN);

verify(driver, times(2)).findElements(by);
}

@Test(expectedExceptions = LocatorException.class)
public void waitFor_hidden_elementGoesStaleAndReRendersStillDisplayed_throwsLocatorException() {
WebElement stale = mock(WebElement.class);
when(stale.isDisplayed()).thenThrow(new StaleElementReferenceException("re-rendered"));
WebElement freshVisible = liveElement();
By by = By.id("spinner");
when(driver.findElements(by)).thenReturn(List.of(stale)).thenReturn(List.of(freshVisible));

Locator.of(by).waitFor(State.HIDDEN);
}

@Test
public void allTextContents_elementGoesStaleBetweenResolveAndGetText_reResolves() {
WebElement el1 = liveElement();
when(el1.getText()).thenReturn("Alpha");
WebElement staleEl2 = liveElement();
when(staleEl2.getText()).thenThrow(new StaleElementReferenceException("re-rendered"));

WebElement freshEl1 = liveElement();
when(freshEl1.getText()).thenReturn("Alpha");
WebElement freshEl2 = liveElement();
when(freshEl2.getText()).thenReturn("Beta");

By by = By.cssSelector(".item");
when(driver.findElements(by))
.thenReturn(List.of(el1, staleEl2))
.thenReturn(List.of(freshEl1, freshEl2));

assertEquals(Locator.of(by).allTextContents(), List.of("Alpha", "Beta"));
verify(driver, times(2)).findElements(by);
}

@Test
public void screenshot_elementGoesStaleBetweenCheckAndCapture_reResolvesAndCaptures() {
WebElement stale = liveElement();
when(stale.getScreenshotAs(OutputType.BYTES))
.thenThrow(new StaleElementReferenceException("re-rendered"));
WebElement fresh = liveElement();
byte[] expected = new byte[]{1, 2, 3};
when(fresh.getScreenshotAs(OutputType.BYTES)).thenReturn(expected);

By by = By.id("card");
when(driver.findElements(by)).thenReturn(List.of(stale)).thenReturn(List.of(fresh));

byte[] result = Locator.of(by).screenshot();

assertEquals(result, expected);
verify(stale, times(1)).getScreenshotAs(OutputType.BYTES);
verify(fresh, times(1)).getScreenshotAs(OutputType.BYTES);
}
}
Loading
Loading