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