diff --git a/CHANGELOG.md b/CHANGELOG.md index b4e553b..a01d5eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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: `/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) diff --git a/docs-site/docs/changelog.md b/docs-site/docs/changelog.md index 97172f3..70e1602 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`: `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: `/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) diff --git a/docs-site/docs/guides/semantic-locators.md b/docs-site/docs/guides/semantic-locators.md index b7f9292..a90ddaa 100644 --- a/docs-site/docs/guides/semantic-locators.md +++ b/docs-site/docs/guides/semantic-locators.md @@ -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. --- diff --git a/src/main/java/com/seleniumboot/locator/Locator.java b/src/main/java/com/seleniumboot/locator/Locator.java index 48184c5..0051ae6 100644 --- a/src/main/java/com/seleniumboot/locator/Locator.java +++ b/src/main/java/com/seleniumboot/locator/Locator.java @@ -7,6 +7,7 @@ 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; @@ -14,8 +15,12 @@ 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; @@ -286,6 +291,49 @@ public By toBy() { // Terminal actions — all auto-wait // ------------------------------------------------------------------ + /** + * Waits for the element to reach the specified {@link State}. + * + *

Re-resolves from scratch on every poll and retries stale or not-yet-present + * elements until {@code timeouts.explicit} elapses. + * + *

+     * $("#spinner").waitFor(State.DETACHED);
+     * $(".toast").waitFor(State.VISIBLE);
+     * 
+ * + * @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. + * + *

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. * @@ -500,11 +548,94 @@ public void selectOptionByValue(String value) { }); } + /** + * Captures a PNG screenshot of the resolved element and returns its bytes. + * + *

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. + * + *

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. + * + *

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 allTextContents() { + SeleniumBootConfig cfg = SeleniumBootContext.getConfig(); + int timeout = cfg.getTimeouts().getExplicit(); + AtomicReference> result = new AtomicReference<>(); + new WebDriverWait(driver(), Duration.ofSeconds(timeout)) + .ignoring(StaleElementReferenceException.class) + .until(d -> { + try { + List candidates = resolveAll(); + if (candidates.isEmpty()) { + result.set(Collections.emptyList()); + return Boolean.TRUE; + } + List 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); @@ -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 whenVisible(Function action) { return whenReady(WebElement::isDisplayed, action); } diff --git a/src/main/java/com/seleniumboot/locator/State.java b/src/main/java/com/seleniumboot/locator/State.java new file mode 100644 index 0000000..b348a59 --- /dev/null +++ b/src/main/java/com/seleniumboot/locator/State.java @@ -0,0 +1,41 @@ +package com.seleniumboot.locator; + +import com.seleniumboot.api.SeleniumBootApi; + +/** + * Element lifecycle states for {@link Locator#waitFor(State)}. + * + *

Mirrors Playwright's {@code locator.waitFor({ state })}: + *

+ * + * @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 +} diff --git a/src/test/java/com/seleniumboot/unit/LocatorStaleElementTest.java b/src/test/java/com/seleniumboot/unit/LocatorStaleElementTest.java index 8b86aee..ef74abe 100644 --- a/src/test/java/com/seleniumboot/unit/LocatorStaleElementTest.java +++ b/src/test/java/com/seleniumboot/unit/LocatorStaleElementTest.java @@ -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; @@ -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); + } } diff --git a/src/test/java/com/seleniumboot/unit/LocatorTest.java b/src/test/java/com/seleniumboot/unit/LocatorTest.java index b6b2259..a84c7e4 100644 --- a/src/test/java/com/seleniumboot/unit/LocatorTest.java +++ b/src/test/java/com/seleniumboot/unit/LocatorTest.java @@ -18,6 +18,12 @@ import java.util.Arrays; import java.util.Collections; import com.seleniumboot.locator.Role; +import com.seleniumboot.locator.State; +import org.openqa.selenium.OutputType; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.List; import java.util.Map; @@ -686,4 +692,184 @@ public void inputValue_returnsEmptyStringWhenBothAreNull() { assertEquals(Locator.of(By.id("email")).inputValue(), ""); } + + // ------------------------------------------------------------------ + // waitFor(State), allTextContents, and screenshot + // ------------------------------------------------------------------ + + @Test + public void waitFor_visible_succeedsWhenDisplayed() { + WebElement el = mock(WebElement.class); + when(el.isDisplayed()).thenReturn(true); + when(mockDriver.findElements(By.id("toast"))).thenReturn(List.of(el)); + + Locator.of(By.id("toast")).waitFor(State.VISIBLE); + // Completed without exception + } + + @Test(expectedExceptions = LocatorException.class) + public void waitFor_visible_throwsWhenElementNeverAppears() { + when(mockDriver.findElements(By.id("missing"))).thenReturn(Collections.emptyList()); + + Locator.of(By.id("missing")).waitFor(State.VISIBLE); + } + + @Test(expectedExceptions = LocatorException.class) + public void waitFor_visible_throwsWhenElementNotDisplayed() { + WebElement el = mock(WebElement.class); + when(el.isDisplayed()).thenReturn(false); + when(mockDriver.findElements(By.id("hidden-box"))).thenReturn(List.of(el)); + + Locator.of(By.id("hidden-box")).waitFor(State.VISIBLE); + } + + @Test + public void waitFor_default_waitsForVisible() { + WebElement el = mock(WebElement.class); + when(el.isDisplayed()).thenReturn(true); + when(mockDriver.findElements(By.id("toast"))).thenReturn(List.of(el)); + + Locator.of(By.id("toast")).waitFor(); + } + + @Test + public void waitFor_hidden_succeedsWhenNotDisplayed() { + WebElement el = mock(WebElement.class); + when(el.isDisplayed()).thenReturn(false); + when(mockDriver.findElements(By.id("spinner"))).thenReturn(List.of(el)); + + Locator.of(By.id("spinner")).waitFor(State.HIDDEN); + } + + @Test + public void waitFor_hidden_succeedsWhenNotInDom() { + when(mockDriver.findElements(By.id("spinner"))).thenReturn(Collections.emptyList()); + + Locator.of(By.id("spinner")).waitFor(State.HIDDEN); + } + + @Test(expectedExceptions = LocatorException.class) + public void waitFor_hidden_throwsWhenElementRemainsVisible() { + WebElement el = mock(WebElement.class); + when(el.isDisplayed()).thenReturn(true); + when(mockDriver.findElements(By.id("banner"))).thenReturn(List.of(el)); + + Locator.of(By.id("banner")).waitFor(State.HIDDEN); + } + + @Test + public void waitFor_attached_succeedsWhenInDomEvenIfHidden() { + WebElement el = mock(WebElement.class); + when(el.isDisplayed()).thenReturn(false); + when(el.isEnabled()).thenReturn(true); + when(mockDriver.findElements(By.id("hidden-input"))).thenReturn(List.of(el)); + + Locator.of(By.id("hidden-input")).waitFor(State.ATTACHED); + } + + @Test(expectedExceptions = LocatorException.class) + public void waitFor_attached_throwsWhenElementNotInDom() { + when(mockDriver.findElements(By.id("missing"))).thenReturn(Collections.emptyList()); + + Locator.of(By.id("missing")).waitFor(State.ATTACHED); + } + + @Test + public void waitFor_detached_succeedsWhenNotInDom() { + when(mockDriver.findElements(By.id("removed"))).thenReturn(Collections.emptyList()); + + Locator.of(By.id("removed")).waitFor(State.DETACHED); + } + + @Test(expectedExceptions = LocatorException.class) + public void waitFor_detached_throwsWhenElementRemainsInDom() { + WebElement el = mock(WebElement.class); + when(el.isEnabled()).thenReturn(true); + when(mockDriver.findElements(By.id("present"))).thenReturn(List.of(el)); + + Locator.of(By.id("present")).waitFor(State.DETACHED); + } + + @Test + public void allTextContents_returnsAllMatchesText() { + WebElement el1 = mock(WebElement.class); + when(el1.getText()).thenReturn(" Alpha "); + WebElement el2 = mock(WebElement.class); + when(el2.getText()).thenReturn("Beta"); + WebElement el3 = mock(WebElement.class); + when(el3.getText()).thenReturn(" Gamma"); + + when(mockDriver.findElements(By.cssSelector(".item"))).thenReturn(List.of(el1, el2, el3)); + + List texts = Locator.ofCss(".item").allTextContents(); + + assertEquals(texts, List.of("Alpha", "Beta", "Gamma")); + } + + @Test + public void allTextContents_fallsBackToDomPropertyWhenGetTextEmpty() { + WebElement el1 = mock(WebElement.class); + when(el1.getText()).thenReturn(""); + when(el1.getDomProperty("textContent")).thenReturn("Hidden Alpha"); + + when(mockDriver.findElements(By.cssSelector(".item"))).thenReturn(List.of(el1)); + + List texts = Locator.ofCss(".item").allTextContents(); + + assertEquals(texts, List.of("Hidden Alpha")); + } + + @Test + public void allTextContents_returnsEmptyListWhenNoMatches_noWait() { + when(mockDriver.findElements(By.cssSelector(".missing"))).thenReturn(Collections.emptyList()); + + List texts = Locator.ofCss(".missing").allTextContents(); + + assertNotNull(texts); + assertTrue(texts.isEmpty()); + } + + @Test + public void screenshot_returnsPngBytes() { + WebElement el = mock(WebElement.class); + when(el.isDisplayed()).thenReturn(true); + byte[] expectedBytes = new byte[]{(byte) 0x89, 0x50, 0x4E, 0x47}; + when(el.getScreenshotAs(OutputType.BYTES)).thenReturn(expectedBytes); + + when(mockDriver.findElements(By.id("card"))).thenReturn(List.of(el)); + + byte[] bytes = Locator.of(By.id("card")).screenshot(); + + assertEquals(bytes, expectedBytes); + verify(el, times(1)).getScreenshotAs(OutputType.BYTES); + } + + @Test + public void screenshot_writesToPath() throws IOException { + WebElement el = mock(WebElement.class); + when(el.isDisplayed()).thenReturn(true); + byte[] expectedBytes = new byte[]{(byte) 0x89, 0x50, 0x4E, 0x47, 0x01, 0x02}; + when(el.getScreenshotAs(OutputType.BYTES)).thenReturn(expectedBytes); + + when(mockDriver.findElements(By.id("card"))).thenReturn(List.of(el)); + + Path tempDir = Files.createTempDirectory("locator-screenshot-test"); + Path subDir = tempDir.resolve("nested"); + Path targetFile = subDir.resolve("element.png"); + try { + Locator.of(By.id("card")).screenshot(targetFile); + + assertTrue(Files.exists(targetFile)); + assertEquals(Files.readAllBytes(targetFile), expectedBytes); + } finally { + Files.deleteIfExists(targetFile); + Files.deleteIfExists(subDir); + Files.deleteIfExists(tempDir); + } + } + + @Test(expectedExceptions = IllegalArgumentException.class) + public void screenshot_nullPath_throwsIllegalArgumentException() { + Locator.of(By.id("card")).screenshot(null); + } }