From 60e19a6006701203f2dfd8474612639e8947b7d8 Mon Sep 17 00:00:00 2001 From: Pushpak-Jaiswal Date: Mon, 12 Oct 2026 10:58:15 +0530 Subject: [PATCH 1/2] feat(locator): waitFor, allTextContents, element screenshot (#135) --- CHANGELOG.md | 2 + docs-site/docs/changelog.md | 2 + docs-site/docs/guides/semantic-locators.md | 4 +- .../com/seleniumboot/locator/Locator.java | 162 +++++++++++++++ .../java/com/seleniumboot/locator/State.java | 42 ++++ .../unit/LocatorStaleElementTest.java | 45 +++++ .../com/seleniumboot/unit/LocatorTest.java | 187 ++++++++++++++++++ 7 files changed, 443 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/seleniumboot/locator/State.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 4147da7..99c4227 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. `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) + - **Failure grouping** — the report's Failures tab now opens with a "N tests, same cause" panel: failed tests are grouped by a signature of category, exception type, normalised message (ids, numbers and quoted values stripped) and top application stack frame. The flat per-class list is unchanged below it. With `ai.failureAnalysis` on, one Claude call is made per group; the other tests in the group reuse its analysis. The metrics JSON gains `failureSignature`. (#140) - **Failure classification** — every failed test is tagged with a category (`LOCATOR_NOT_FOUND`, `ELEMENT_NOT_INTERACTABLE`, `STALE_ELEMENT`, `TIMEOUT`, `ASSERTION_MISMATCH`, `NAVIGATION_ERROR`, `BROWSER_CRASH`, or `UNKNOWN`) and the evidence for it, from the exception class chain and message alone. No AI, no API key, no network. Shown as a badge in the HTML report's failure panel, as `failureCategory` / `failureEvidence` in the metrics JSON, and as the `type` of `` in the JUnit XML. The raw error and stack trace are unchanged. (#139) diff --git a/docs-site/docs/changelog.md b/docs-site/docs/changelog.md index f940300..539d6f3 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. `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) + - **Failure grouping** — the report's Failures tab now opens with a "N tests, same cause" panel: failed tests are grouped by a signature of category, exception type, normalised message (ids, numbers and quoted values stripped) and top application stack frame. The flat per-class list is unchanged below it. With `ai.failureAnalysis` on, one Claude call is made per group; the other tests in the group reuse its analysis. The metrics JSON gains `failureSignature`. (#140) - **`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) 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..f48b8a1 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,79 @@ 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. + * + *

Returns an empty list immediately if no elements match. + * + * @return list of text contents of all matched elements + */ + public List allTextContents() { + try { + List candidates = resolveAll(); + if (candidates.isEmpty()) { + return Collections.emptyList(); + } + 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() : ""); + } + return texts; + } catch (LocatorException e) { + return Collections.emptyList(); + } + } + /** Returns the resolved {@link WebElement}, applying all chain filters. */ public WebElement element() { return whenVisible(el -> el); @@ -863,6 +979,52 @@ 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 | StaleElementReferenceException e) { + return Boolean.TRUE; + } + } + 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 { + WebElement el = resolve(); + el.isEnabled(); + return null; + } catch (LocatorException | StaleElementReferenceException e) { + return Boolean.TRUE; + } + } + 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..9efc790 --- /dev/null +++ b/src/main/java/com/seleniumboot/locator/State.java @@ -0,0 +1,42 @@ +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..ccd66b3 100644 --- a/src/test/java/com/seleniumboot/unit/LocatorStaleElementTest.java +++ b/src/test/java/com/seleniumboot/unit/LocatorStaleElementTest.java @@ -4,8 +4,10 @@ import com.seleniumboot.driver.DriverManager; import com.seleniumboot.internal.SeleniumBootContext; import com.seleniumboot.locator.Locator; +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 +355,47 @@ 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); + // Completed successfully + } + + @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..26c6698 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,185 @@ 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); + } } + From f7119495a3c8c0d6af9767c033e43913d9d9a05b Mon Sep 17 00:00:00 2001 From: Pushpak-Jaiswal Date: Tue, 13 Oct 2026 11:10:45 +0530 Subject: [PATCH 2/2] updates --- .../com/seleniumboot/locator/Locator.java | 68 ++++++++++++------- .../java/com/seleniumboot/locator/State.java | 1 - .../unit/LocatorStaleElementTest.java | 36 +++++++++- .../com/seleniumboot/unit/LocatorTest.java | 1 - 4 files changed, 78 insertions(+), 28 deletions(-) diff --git a/src/main/java/com/seleniumboot/locator/Locator.java b/src/main/java/com/seleniumboot/locator/Locator.java index f48b8a1..0051ae6 100644 --- a/src/main/java/com/seleniumboot/locator/Locator.java +++ b/src/main/java/com/seleniumboot/locator/Locator.java @@ -592,33 +592,48 @@ public int count() { } /** - * Returns the text content of all elements matching this locator chain, without waiting. + * 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 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() { - try { - List candidates = resolveAll(); - if (candidates.isEmpty()) { - return Collections.emptyList(); - } - 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; + 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; } - } - texts.add(text != null ? text.trim() : ""); - } - return texts; - } catch (LocatorException e) { - return Collections.emptyList(); - } + }); + return result.get(); } /** Returns the resolved {@link WebElement}, applying all chain filters. */ @@ -996,8 +1011,10 @@ private Boolean checkState(State state) { try { WebElement el = resolve(); return !el.isDisplayed() ? Boolean.TRUE : null; - } catch (LocatorException | StaleElementReferenceException e) { + } catch (LocatorException e) { return Boolean.TRUE; + } catch (StaleElementReferenceException e) { + return null; } } case ATTACHED: { @@ -1013,11 +1030,12 @@ private Boolean checkState(State state) { } case DETACHED: { try { - WebElement el = resolve(); - el.isEnabled(); + resolve(); return null; - } catch (LocatorException | StaleElementReferenceException e) { + } catch (LocatorException e) { return Boolean.TRUE; + } catch (StaleElementReferenceException e) { + return null; } } default: diff --git a/src/main/java/com/seleniumboot/locator/State.java b/src/main/java/com/seleniumboot/locator/State.java index 9efc790..b348a59 100644 --- a/src/main/java/com/seleniumboot/locator/State.java +++ b/src/main/java/com/seleniumboot/locator/State.java @@ -39,4 +39,3 @@ public enum State { */ DETACHED } - diff --git a/src/test/java/com/seleniumboot/unit/LocatorStaleElementTest.java b/src/test/java/com/seleniumboot/unit/LocatorStaleElementTest.java index ccd66b3..ef74abe 100644 --- a/src/test/java/com/seleniumboot/unit/LocatorStaleElementTest.java +++ b/src/test/java/com/seleniumboot/unit/LocatorStaleElementTest.java @@ -4,6 +4,7 @@ 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; @@ -377,7 +378,40 @@ public void waitFor_hidden_elementGoesStaleWhileChecking_reResolves() { when(driver.findElements(by)).thenReturn(List.of(stale)).thenReturn(List.of()); Locator.of(by).waitFor(State.HIDDEN); - // Completed successfully + + 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 diff --git a/src/test/java/com/seleniumboot/unit/LocatorTest.java b/src/test/java/com/seleniumboot/unit/LocatorTest.java index 26c6698..a84c7e4 100644 --- a/src/test/java/com/seleniumboot/unit/LocatorTest.java +++ b/src/test/java/com/seleniumboot/unit/LocatorTest.java @@ -873,4 +873,3 @@ public void screenshot_nullPath_throwsIllegalArgumentException() { Locator.of(By.id("card")).screenshot(null); } } -