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.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.
Expand Down
6 changes: 6 additions & 0 deletions docs-site/docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion docs-site/docs/guides/semantic-locators.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
56 changes: 53 additions & 3 deletions src/main/java/com/seleniumboot/locator/Locator.java
Original file line number Diff line number Diff line change
Expand Up @@ -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*()
Expand Down Expand Up @@ -199,10 +201,45 @@ public Locator within(By container) {
* <pre>$(".item").nth(2).getText()</pre>
*/
public Locator nth(int index) {
this.first = false;
this.last = false;
this.nthIndex = index;
return this;
}

/**
* Narrow results to the first matched element.
*
* <p>Equivalent to {@link #nth(int) nth(0)}.
*
* <pre>$(".item").first().click();</pre>
*
* @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.
*
* <p>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.
*
* <pre>$(".item").last().click();</pre>
*
* @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.
Expand Down Expand Up @@ -553,11 +590,22 @@ private List<WebElement> 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<WebElement> 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<WebElement> single = new ArrayList<>();
Expand Down Expand Up @@ -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();
}

Expand Down
49 changes: 49 additions & 0 deletions src/test/java/com/seleniumboot/unit/LocatorStaleElementTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
156 changes: 156 additions & 0 deletions src/test/java/com/seleniumboot/unit/LocatorTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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.*;

Expand Down Expand Up @@ -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 <tr>} with the given {@code <th>} and {@code <td>} children. */
Expand Down