diff --git a/CHANGELOG.md b/CHANGELOG.md index 862c23b..0e94ade 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ### Unreleased +- **`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) + - **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. diff --git a/docs-site/docs/changelog.md b/docs-site/docs/changelog.md index 70d29c4..17291ae 100644 --- a/docs-site/docs/changelog.md +++ b/docs-site/docs/changelog.md @@ -11,6 +11,12 @@ All notable changes to Selenium Boot are documented here. --- +## [Unreleased] + +- **`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) + +--- + ## [3.7.0] — 2026-10-04 - **`Locator.dragTo(Locator)`** — drags one element onto another, including HTML5 drag-and-drop pages that ignore the native gesture. See [Drag and drop](./recipes/drag-and-drop). (#101) diff --git a/docs-site/docs/guides/semantic-locators.md b/docs-site/docs/guides/semantic-locators.md index 5065308..a807398 100644 --- a/docs-site/docs/guides/semantic-locators.md +++ b/docs-site/docs/guides/semantic-locators.md @@ -22,7 +22,7 @@ getByTestId("checkout-cta").click(); 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`. +on both `BaseTest` and `BasePage`. Narrow multiple matches using `.first()`, `.last()`, or `.nth(index)` (e.g. `getByRole(Role.LISTITEM).first().click()`). 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 diff --git a/src/main/java/com/seleniumboot/locator/Locator.java b/src/main/java/com/seleniumboot/locator/Locator.java index 9a89a41..9f71577 100644 --- a/src/main/java/com/seleniumboot/locator/Locator.java +++ b/src/main/java/com/seleniumboot/locator/Locator.java @@ -88,6 +88,8 @@ private enum Kind { CSS_OR_BY, ROLE, TEXT, LABEL, PLACEHOLDER, TESTID, ALT_TEXT, private String withText; private By withinContainer; private int nthIndex = -1; // -1 = no nth filter + private boolean first; + private boolean last; // ------------------------------------------------------------------ // Factory — called from BasePage / BaseTest via $() and getBy*() @@ -199,10 +201,45 @@ public Locator within(By container) { *
$(".item").nth(2).getText()
*/ public Locator nth(int index) { + this.first = false; + this.last = false; this.nthIndex = index; return this; } + /** + * Narrow results to the first matched element. + * + *

Equivalent to {@link #nth(int) nth(0)}. + * + *

$(".item").first().click();
+ * + * @return this locator for chaining + */ + public Locator first() { + this.first = true; + this.last = false; + this.nthIndex = 0; + return this; + } + + /** + * Narrow results to the last matched element. + * + *

Resolves lazily at action time on every poll, ensuring dynamic element lists + * and auto-waiting reflect the current DOM state when the terminal action runs. + * + *

$(".item").last().click();
+ * + * @return this locator for chaining + */ + public Locator last() { + this.last = true; + this.first = false; + this.nthIndex = -1; + return this; + } + /** * Narrow a role-based locator to elements whose accessible name matches. * Case-insensitive substring by default; call {@link #exact()} for an exact match. @@ -553,11 +590,22 @@ private List resolveAll() { .collect(Collectors.toList()); } - // apply nth + // apply nth / first / last + if (last) { + if (candidates.isEmpty()) { + throw new LocatorException( + "last() requested but only 0 element(s) matched: " + describe()); + } + List single = new ArrayList<>(); + single.add(candidates.get(candidates.size() - 1)); + return single; + } + if (nthIndex >= 0) { if (nthIndex >= candidates.size()) { + String prefix = first ? "first()" : ("nth(" + nthIndex + ")"); throw new LocatorException( - "nth(" + nthIndex + ") requested but only " + candidates.size() + prefix + " requested but only " + candidates.size() + " element(s) matched: " + describe()); } List single = new ArrayList<>(); @@ -783,7 +831,9 @@ private String describe() { if (withinContainer != null) sb.append(" within(").append(withinContainer).append(")"); if (filterCss != null) sb.append(".filter(\"").append(filterCss).append("\")"); if (withText != null) sb.append(".withText(\"").append(withText).append("\")"); - if (nthIndex >= 0) sb.append(".nth(").append(nthIndex).append(")"); + if (last) sb.append(".last()"); + else if (first) sb.append(".first()"); + else if (nthIndex >= 0) sb.append(".nth(").append(nthIndex).append(")"); return sb.toString(); } diff --git a/src/test/java/com/seleniumboot/unit/LocatorStaleElementTest.java b/src/test/java/com/seleniumboot/unit/LocatorStaleElementTest.java index 216f097..3e68dab 100644 --- a/src/test/java/com/seleniumboot/unit/LocatorStaleElementTest.java +++ b/src/test/java/com/seleniumboot/unit/LocatorStaleElementTest.java @@ -190,4 +190,53 @@ public void jsClick_elementGoesStale_reResolves() { verify(js).executeScript(anyString(), eq(fresh)); } + + @Test + public void last_elementGoesStaleBetweenCheckAndClick_reResolvesAndClicksFreshLastElement() { + WebElement other = liveElement(); + WebElement staleLast = liveElement(); + doThrow(new StaleElementReferenceException("re-rendered")).when(staleLast).click(); + WebElement freshLast = liveElement(); + By by = By.cssSelector(".items"); + when(driver.findElements(by)) + .thenReturn(List.of(other, staleLast)) + .thenReturn(List.of(other, freshLast)); + + Locator.of(by).last().click(); + + verify(freshLast, times(1)).click(); + verify(staleLast, times(1)).click(); + } + + @Test + public void last_elementNotInDomYet_waitsForItToAppear() { + WebElement el1 = liveElement(); + WebElement el2 = liveElement(); + By by = By.cssSelector(".late-items"); + when(driver.findElements(by)) + .thenReturn(List.of()) + .thenReturn(List.of()) + .thenReturn(List.of(el1, el2)); + + Locator.of(by).last().click(); + + verify(el2, times(1)).click(); + } + + @Test + public void first_elementGoesStaleBetweenCheckAndClick_reResolvesAndClicksFreshFirstElement() { + WebElement staleFirst = liveElement(); + doThrow(new StaleElementReferenceException("re-rendered")).when(staleFirst).click(); + WebElement freshFirst = liveElement(); + WebElement other = liveElement(); + By by = By.cssSelector(".items"); + when(driver.findElements(by)) + .thenReturn(List.of(staleFirst, other)) + .thenReturn(List.of(freshFirst, other)); + + Locator.of(by).first().click(); + + verify(freshFirst, times(1)).click(); + verify(staleFirst, times(1)).click(); + } } diff --git a/src/test/java/com/seleniumboot/unit/LocatorTest.java b/src/test/java/com/seleniumboot/unit/LocatorTest.java index 90d234c..2102fa9 100644 --- a/src/test/java/com/seleniumboot/unit/LocatorTest.java +++ b/src/test/java/com/seleniumboot/unit/LocatorTest.java @@ -17,11 +17,13 @@ import java.util.Arrays; import java.util.Collections; +import com.seleniumboot.locator.Role; import java.util.List; import java.util.Map; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; import static org.testng.Assert.*; @@ -305,6 +307,160 @@ public void rows_resolvesThroughTheChain_whenNthIsApplied() { assertEquals(rows.get(0).get("Name"), "Second"); } + // ══════════════════════════════════════════════════════════════════════════ + // first() & last() + // ══════════════════════════════════════════════════════════════════════════ + + @Test + public void first_returnsFirstMatchedElement() { + WebElement el1 = mock(WebElement.class); + when(el1.isDisplayed()).thenReturn(true); + when(el1.getText()).thenReturn("Item 1"); + + WebElement el2 = mock(WebElement.class); + when(el2.isDisplayed()).thenReturn(true); + when(el2.getText()).thenReturn("Item 2"); + + when(mockDriver.findElements(By.cssSelector(".item"))).thenReturn(Arrays.asList(el1, el2)); + + String text = Locator.ofCss(".item").first().getText(); + assertEquals(text, "Item 1"); + } + + @Test + public void last_returnsLastMatchedElement() { + WebElement el1 = mock(WebElement.class); + when(el1.isDisplayed()).thenReturn(true); + when(el1.getText()).thenReturn("Item 1"); + + WebElement el2 = mock(WebElement.class); + when(el2.isDisplayed()).thenReturn(true); + when(el2.getText()).thenReturn("Item 2"); + + WebElement el3 = mock(WebElement.class); + when(el3.isDisplayed()).thenReturn(true); + when(el3.getText()).thenReturn("Item 3"); + + when(mockDriver.findElements(By.cssSelector(".item"))).thenReturn(Arrays.asList(el1, el2, el3)); + + String text = Locator.ofCss(".item").last().getText(); + assertEquals(text, "Item 3"); + } + + @Test + public void first_and_last_onSingleElement_returnThatElement() { + WebElement el = mock(WebElement.class); + when(el.isDisplayed()).thenReturn(true); + when(el.getText()).thenReturn("Only Item"); + + when(mockDriver.findElements(By.cssSelector(".single"))).thenReturn(Collections.singletonList(el)); + + assertEquals(Locator.ofCss(".single").first().getText(), "Only Item"); + assertEquals(Locator.ofCss(".single").last().getText(), "Only Item"); + } + + @Test + public void last_resolvesLazilyAtActionTime() { + // last() should NOT query the driver at build time + Locator locator = Locator.ofCss(".item").last(); + verifyNoInteractions(mockDriver); + + WebElement el1 = mock(WebElement.class); + when(el1.isDisplayed()).thenReturn(true); + when(el1.getText()).thenReturn("First"); + + WebElement el2 = mock(WebElement.class); + when(el2.isDisplayed()).thenReturn(true); + when(el2.getText()).thenReturn("Second"); + + WebElement el3 = mock(WebElement.class); + when(el3.isDisplayed()).thenReturn(true); + when(el3.getText()).thenReturn("Third"); + + // Action 1: 2 items in DOM + when(mockDriver.findElements(By.cssSelector(".item"))).thenReturn(Arrays.asList(el1, el2)); + assertEquals(locator.getText(), "Second"); + + // Action 2: 3 items in DOM now — last() dynamically gets the 3rd item + when(mockDriver.findElements(By.cssSelector(".item"))).thenReturn(Arrays.asList(el1, el2, el3)); + assertEquals(locator.getText(), "Third"); + } + + @Test(expectedExceptions = LocatorException.class) + public void first_throwsLocatorException_whenNoElementsMatch() { + when(mockDriver.findElements(By.cssSelector(".missing"))).thenReturn(Collections.emptyList()); + + Locator.ofCss(".missing").first().element(); + } + + @Test(expectedExceptions = LocatorException.class) + public void last_throwsLocatorException_whenNoElementsMatch() { + when(mockDriver.findElements(By.cssSelector(".missing"))).thenReturn(Collections.emptyList()); + + Locator.ofCss(".missing").last().element(); + } + + @Test + public void first_and_last_exceptionMessage_identifiesRequestedNarrowing() { + when(mockDriver.findElements(By.cssSelector(".missing"))).thenReturn(Collections.emptyList()); + + try { + Locator.ofCss(".missing").first().element(); + fail("Expected LocatorException"); + } catch (LocatorException e) { + assertTrue(e.getMessage().contains("first() requested"), "Message should mention first()"); + assertTrue(e.getMessage().contains(".first()"), "Description should include .first()"); + } + + try { + Locator.ofCss(".missing").last().element(); + fail("Expected LocatorException"); + } catch (LocatorException e) { + assertTrue(e.getMessage().contains("last() requested"), "Message should mention last()"); + assertTrue(e.getMessage().contains(".last()"), "Description should include .last()"); + } + } + + @Test + public void first_and_last_toString_formatting() { + Locator firstLoc = Locator.ofCss(".item").first(); + assertTrue(firstLoc.toString().contains(".first()"), "toString should include .first()"); + assertFalse(firstLoc.toString().contains(".nth("), "toString should not include .nth(0)"); + + Locator lastLoc = Locator.ofCss(".item").last(); + assertTrue(lastLoc.toString().contains(".last()"), "toString should include .last()"); + assertFalse(lastLoc.toString().contains(".nth("), "toString should not include .nth"); + + Locator roleFirst = Locator.byRole(Role.LISTITEM).first(); + assertTrue(roleFirst.toString().contains("getByRole(listitem).first()")); + + Locator roleLast = Locator.byRole(Role.LISTITEM).last(); + assertTrue(roleLast.toString().contains("getByRole(listitem).last()")); + } + + @Test + public void first_last_nth_overridePrecedence() { + Locator loc1 = Locator.ofCss(".item").first().last(); + assertTrue(loc1.toString().contains(".last()")); + assertFalse(loc1.toString().contains(".first()")); + + Locator loc2 = Locator.ofCss(".item").last().first(); + assertTrue(loc2.toString().contains(".first()")); + assertFalse(loc2.toString().contains(".last()")); + + Locator loc3 = Locator.ofCss(".item").nth(3).first(); + assertTrue(loc3.toString().contains(".first()")); + assertFalse(loc3.toString().contains(".nth(3)")); + + Locator loc4 = Locator.ofCss(".item").first().nth(2); + assertTrue(loc4.toString().contains(".nth(2)")); + assertFalse(loc4.toString().contains(".first()")); + + Locator loc5 = Locator.ofCss(".item").last().nth(2); + assertTrue(loc5.toString().contains(".nth(2)")); + assertFalse(loc5.toString().contains(".last()")); + } + // ── rows() fixtures ─────────────────────────────────────────────────────── /** Mocks a {@code } with the given {@code } and {@code } children. */