From ec37b730a742f336de5b3f2f3cb5fd5c155e6586 Mon Sep 17 00:00:00 2001 From: Timothy Kim <38541174+taeohkim98@users.noreply.github.com> Date: Thu, 17 Sep 2026 15:18:00 -0500 Subject: [PATCH 1/2] feat: add header-keyed table row extraction --- .../com/seleniumboot/locator/Locator.java | 94 +++++++ .../com/seleniumboot/unit/LocatorTest.java | 244 ++++++++++++++++++ 2 files changed, 338 insertions(+) diff --git a/src/main/java/com/seleniumboot/locator/Locator.java b/src/main/java/com/seleniumboot/locator/Locator.java index ca29604..f164d20 100644 --- a/src/main/java/com/seleniumboot/locator/Locator.java +++ b/src/main/java/com/seleniumboot/locator/Locator.java @@ -14,7 +14,9 @@ import java.time.Duration; import java.util.ArrayList; import java.util.List; +import java.util.Map; import java.util.stream.Collectors; +import java.util.LinkedHashMap; /** * Chainable, auto-waiting element locator. @@ -118,6 +120,98 @@ public static void setTestIdAttribute(String attribute) { } } + public List> rows() { + WebElement table = element(); + + List allRows = table.findElements(By.cssSelector("tr")); + + // Empty table + if (allRows.isEmpty()) { + return new ArrayList<>(); + } + + WebElement headerRow = null; + List headerCells = new ArrayList<>(); + + // 1. Try to find header cells inside + List theadRows = table.findElements(By.cssSelector("thead tr")); + + if (!theadRows.isEmpty()) { + List theadHeaders = + theadRows.get(0).findElements(By.cssSelector("th")); + + if (!theadHeaders.isEmpty()) { + headerRow = theadRows.get(0); + headerCells = theadHeaders; + } + } + + // 2. No header -> find the first containing + if (headerCells.isEmpty()) { + for (WebElement row : allRows) { + List thCells = + row.findElements(By.cssSelector("th")); + + if (!thCells.isEmpty()) { + headerRow = row; + headerCells = thCells; + break; + } + } + } + + // 3. No at all -> use the first as the header + if (headerCells.isEmpty()) { + headerRow = allRows.get(0); + headerCells = headerRow.findElements(By.cssSelector("td")); + } + + // Convert header WebElements into Strings + List headers = headerCells.stream() + .map(WebElement::getText) + .map(String::trim) + .collect(Collectors.toList()); + + if (headers.isEmpty()) { + return new ArrayList<>(); + } + + List> result = new ArrayList<>(); + + // Convert each data row into a Map + for (WebElement row : allRows) { + + // Do not treat the header row as data + if (row.equals(headerRow)) { + continue; + } + + List cells = + row.findElements(By.cssSelector("td")); + + if (cells.isEmpty()) { + continue; + } + + Map rowMap = + new LinkedHashMap<>(); + + for (int i = 0; i < headers.size(); i++) { + String header = headers.get(i); + + String cellText = i < cells.size() + ? cells.get(i).getText().trim() + : ""; + + rowMap.put(header, cellText); + } + + result.add(rowMap); + } + + return result; + } + private static Locator semantic(Kind kind, String value) { Locator l = new Locator(kind); l.semanticValue = value; diff --git a/src/test/java/com/seleniumboot/unit/LocatorTest.java b/src/test/java/com/seleniumboot/unit/LocatorTest.java index 086d958..90d234c 100644 --- a/src/test/java/com/seleniumboot/unit/LocatorTest.java +++ b/src/test/java/com/seleniumboot/unit/LocatorTest.java @@ -1,18 +1,28 @@ package com.seleniumboot.unit; +import com.seleniumboot.config.SeleniumBootConfig; +import com.seleniumboot.driver.DriverManager; +import com.seleniumboot.internal.SeleniumBootContext; import com.seleniumboot.locator.Locator; import com.seleniumboot.locator.LocatorException; +import org.mockito.MockedStatic; import org.mockito.Mockito; import org.openqa.selenium.By; import org.openqa.selenium.JavascriptExecutor; import org.openqa.selenium.WebDriver; import org.openqa.selenium.WebElement; +import org.testng.annotations.AfterMethod; +import org.testng.annotations.BeforeMethod; import org.testng.annotations.Test; import java.util.Arrays; import java.util.Collections; 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.when; import static org.testng.Assert.*; /** @@ -21,6 +31,33 @@ */ public class LocatorTest { + private WebDriver mockDriver; + private MockedStatic driverManagerMock; + private MockedStatic contextMock; + + @BeforeMethod + public void setup() { + mockDriver = mock(WebDriver.class); + + driverManagerMock = mockStatic(DriverManager.class); + driverManagerMock.when(DriverManager::getDriver).thenReturn(mockDriver); + + // Supply a config so the auto-wait in element() can read timeouts.explicit + SeleniumBootConfig.Timeouts timeouts = new SeleniumBootConfig.Timeouts(); + timeouts.setExplicit(2); + SeleniumBootConfig config = new SeleniumBootConfig(); + config.setTimeouts(timeouts); + + contextMock = mockStatic(SeleniumBootContext.class); + contextMock.when(SeleniumBootContext::getConfig).thenReturn(config); + } + + @AfterMethod + public void teardown() { + driverManagerMock.close(); + contextMock.close(); + } + // LocatorException thrown when no elements match @Test public void resolve_throwsLocatorException_whenNoElementsFound() { @@ -96,4 +133,211 @@ public void locator_chaining_doesNotMutateOriginal() { assertNotNull(filtered); assertTrue(filtered.toString().contains(".active")); } + + // ══════════════════════════════════════════════════════════════════════════ + // rows() — table → List> + // ══════════════════════════════════════════════════════════════════════════ + + @Test + public void rows_mapsHeaderCellsToDataCells_forTheadTable() { + WebElement headerRow = row(cells("Name", "Email"), Collections.emptyList()); + WebElement dataRow1 = row(Collections.emptyList(), cells("Alice", "alice@test.io")); + WebElement dataRow2 = row(Collections.emptyList(), cells("Bob", "bob@test.io")); + table(Arrays.asList(headerRow, dataRow1, dataRow2), Collections.singletonList(headerRow)); + + List> rows = Locator.ofCss("table").rows(); + + assertEquals(rows.size(), 2, "header row must not be returned as data"); + assertEquals(rows.get(0).get("Name"), "Alice"); + assertEquals(rows.get(0).get("Email"), "alice@test.io"); + assertEquals(rows.get(1).get("Name"), "Bob"); + assertEquals(rows.get(1).get("Email"), "bob@test.io"); + } + + @Test + public void rows_preservesColumnOrder() { + WebElement headerRow = row(cells("Id", "Name", "Status"), Collections.emptyList()); + WebElement dataRow = row(Collections.emptyList(), cells("7", "Widget", "Active")); + table(Arrays.asList(headerRow, dataRow), Collections.singletonList(headerRow)); + + Map first = Locator.ofCss("table").rows().get(0); + + assertEquals(Arrays.asList("Id", "Name", "Status"), + Arrays.asList(first.keySet().toArray()), + "keys should keep the header column order"); + } + + @Test + public void rows_trimsHeaderAndCellText() { + WebElement headerRow = row(cells(" Name "), Collections.emptyList()); + WebElement dataRow = row(Collections.emptyList(), cells("\n Alice \t")); + table(Arrays.asList(headerRow, dataRow), Collections.singletonList(headerRow)); + + Map first = Locator.ofCss("table").rows().get(0); + + assertTrue(first.containsKey("Name"), "header key should be trimmed"); + assertEquals(first.get("Name"), "Alice"); + } + + @Test + public void rows_usesFirstRowWithTh_whenThereIsNoThead() { + WebElement headerRow = row(cells("Name", "Email"), Collections.emptyList()); + WebElement dataRow = row(Collections.emptyList(), cells("Alice", "alice@test.io")); + table(Arrays.asList(headerRow, dataRow), Collections.emptyList()); + + List> rows = Locator.ofCss("table").rows(); + + assertEquals(rows.size(), 1); + assertEquals(rows.get(0).get("Email"), "alice@test.io"); + } + + @Test + public void rows_usesFirstRowWithTh_whenTheadHasNoHeaderCells() { + // … — no , so the scan falls through + WebElement theadRow = row(Collections.emptyList(), cells("ignored")); + WebElement headerRow = row(cells("Name"), Collections.emptyList()); + WebElement dataRow = row(Collections.emptyList(), cells("Alice")); + table(Arrays.asList(theadRow, headerRow, dataRow), Collections.singletonList(theadRow)); + + List> rows = Locator.ofCss("table").rows(); + + assertEquals(rows.size(), 2, "the thead row now counts as data"); + assertEquals(rows.get(0).get("Name"), "ignored"); + assertEquals(rows.get(1).get("Name"), "Alice"); + } + + @Test + public void rows_usesFirstRowAsHeader_whenTableHasNoThCells() { + WebElement headerRow = row(Collections.emptyList(), cells("Name", "Email")); + WebElement dataRow = row(Collections.emptyList(), cells("Alice", "alice@test.io")); + table(Arrays.asList(headerRow, dataRow), Collections.emptyList()); + + List> rows = Locator.ofCss("table").rows(); + + assertEquals(rows.size(), 1, "the first row is consumed as the header"); + assertEquals(rows.get(0).get("Name"), "Alice"); + assertEquals(rows.get(0).get("Email"), "alice@test.io"); + } + + @Test + public void rows_padsMissingCellsWithEmptyString() { + WebElement headerRow = row(cells("Name", "Email", "Role"), Collections.emptyList()); + WebElement shortRow = row(Collections.emptyList(), cells("Alice")); + table(Arrays.asList(headerRow, shortRow), Collections.singletonList(headerRow)); + + Map first = Locator.ofCss("table").rows().get(0); + + assertEquals(first.size(), 3, "every header gets a key even when cells are missing"); + assertEquals(first.get("Name"), "Alice"); + assertEquals(first.get("Email"), ""); + assertEquals(first.get("Role"), ""); + } + + @Test + public void rows_ignoresCellsBeyondTheHeaderCount() { + WebElement headerRow = row(cells("Name"), Collections.emptyList()); + WebElement wideRow = row(Collections.emptyList(), cells("Alice", "extra", "more")); + table(Arrays.asList(headerRow, wideRow), Collections.singletonList(headerRow)); + + Map first = Locator.ofCss("table").rows().get(0); + + assertEquals(first.size(), 1); + assertEquals(first.get("Name"), "Alice"); + } + + @Test + public void rows_skipsRowsWithoutDataCells() { + WebElement headerRow = row(cells("Name"), Collections.emptyList()); + WebElement groupRow = row(cells("Group A"), Collections.emptyList()); // th-only sub-header + WebElement dataRow = row(Collections.emptyList(), cells("Alice")); + table(Arrays.asList(headerRow, groupRow, dataRow), Collections.singletonList(headerRow)); + + List> rows = Locator.ofCss("table").rows(); + + assertEquals(rows.size(), 1, "th-only rows carry no data and are skipped"); + assertEquals(rows.get(0).get("Name"), "Alice"); + } + + @Test + public void rows_returnsEmptyList_whenTableHasNoRows() { + table(Collections.emptyList(), Collections.emptyList()); + + assertTrue(Locator.ofCss("table").rows().isEmpty()); + } + + @Test + public void rows_returnsEmptyList_whenNoHeaderCellsCanBeDerived() { + // a single with neither nor + WebElement emptyRow = row(Collections.emptyList(), Collections.emptyList()); + table(Collections.singletonList(emptyRow), Collections.emptyList()); + + assertTrue(Locator.ofCss("table").rows().isEmpty()); + } + + @Test + public void rows_returnsEmptyList_whenHeaderRowIsTheOnlyRow() { + WebElement headerRow = row(cells("Name", "Email"), Collections.emptyList()); + table(Collections.singletonList(headerRow), Collections.singletonList(headerRow)); + + assertTrue(Locator.ofCss("table").rows().isEmpty(), "a header-only table has no data rows"); + } + + @Test(expectedExceptions = LocatorException.class) + public void rows_throwsLocatorException_whenTableIsNotFound() { + when(mockDriver.findElements(By.cssSelector("table"))).thenReturn(Collections.emptyList()); + + Locator.ofCss("table").rows(); + } + + @Test + public void rows_resolvesThroughTheChain_whenNthIsApplied() { + WebElement headerRow = row(cells("Name"), Collections.emptyList()); + WebElement dataRow = row(Collections.emptyList(), cells("Second")); + WebElement first = mock(WebElement.class); + WebElement second = tableElement(Arrays.asList(headerRow, dataRow), + Collections.singletonList(headerRow)); + when(mockDriver.findElements(By.cssSelector("table"))) + .thenReturn(Arrays.asList(first, second)); + + List> rows = Locator.ofCss("table").nth(1).rows(); + + assertEquals(rows.size(), 1); + assertEquals(rows.get(0).get("Name"), "Second"); + } + + // ── rows() fixtures ─────────────────────────────────────────────────────── + + /** Mocks a {@code } with the given {@code } and {@code } children. */ + private WebElement row(List thCells, List tdCells) { + WebElement row = mock(WebElement.class); + when(row.findElements(By.cssSelector("th"))).thenReturn(thCells); + when(row.findElements(By.cssSelector("td"))).thenReturn(tdCells); + return row; + } + + /** Mocks a list of cells whose visible text is the given values. */ + private List cells(String... texts) { + return Arrays.stream(texts).map(text -> { + WebElement cell = mock(WebElement.class); + when(cell.getText()).thenReturn(text); + return cell; + }).collect(java.util.stream.Collectors.toList()); + } + + /** Mocks a visible {@code } — without registering it with the driver. */ + private WebElement tableElement(List allRows, List theadRows) { + WebElement table = mock(WebElement.class); + when(table.isDisplayed()).thenReturn(true); + when(table.findElements(By.cssSelector("tr"))).thenReturn(allRows); + when(table.findElements(By.cssSelector("thead tr"))).thenReturn(theadRows); + return table; + } + + /** Mocks a visible {@code
} and makes it the only match for {@code table}. */ + private WebElement table(List allRows, List theadRows) { + WebElement table = tableElement(allRows, theadRows); + when(mockDriver.findElements(By.cssSelector("table"))) + .thenReturn(Collections.singletonList(table)); + return table; + } } From b2122d5a30cbe2d1de278550e2216942eb2a285a Mon Sep 17 00:00:00 2001 From: Timothy Kim <38541174+taeohkim98@users.noreply.github.com> Date: Thu, 17 Sep 2026 23:22:19 -0500 Subject: [PATCH 2/2] docs(locator): add Javadoc for rows() and tidy its placement --- .../com/seleniumboot/locator/Locator.java | 212 ++++++++++-------- 1 file changed, 119 insertions(+), 93 deletions(-) diff --git a/src/main/java/com/seleniumboot/locator/Locator.java b/src/main/java/com/seleniumboot/locator/Locator.java index f164d20..09c223c 100644 --- a/src/main/java/com/seleniumboot/locator/Locator.java +++ b/src/main/java/com/seleniumboot/locator/Locator.java @@ -13,10 +13,10 @@ import java.time.Duration; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.stream.Collectors; -import java.util.LinkedHashMap; /** * Chainable, auto-waiting element locator. @@ -120,98 +120,6 @@ public static void setTestIdAttribute(String attribute) { } } - public List> rows() { - WebElement table = element(); - - List allRows = table.findElements(By.cssSelector("tr")); - - // Empty table - if (allRows.isEmpty()) { - return new ArrayList<>(); - } - - WebElement headerRow = null; - List headerCells = new ArrayList<>(); - - // 1. Try to find header cells inside - List theadRows = table.findElements(By.cssSelector("thead tr")); - - if (!theadRows.isEmpty()) { - List theadHeaders = - theadRows.get(0).findElements(By.cssSelector("th")); - - if (!theadHeaders.isEmpty()) { - headerRow = theadRows.get(0); - headerCells = theadHeaders; - } - } - - // 2. No header -> find the first containing as the header - if (headerCells.isEmpty()) { - headerRow = allRows.get(0); - headerCells = headerRow.findElements(By.cssSelector("td")); - } - - // Convert header WebElements into Strings - List headers = headerCells.stream() - .map(WebElement::getText) - .map(String::trim) - .collect(Collectors.toList()); - - if (headers.isEmpty()) { - return new ArrayList<>(); - } - - List> result = new ArrayList<>(); - - // Convert each data row into a Map - for (WebElement row : allRows) { - - // Do not treat the header row as data - if (row.equals(headerRow)) { - continue; - } - - List cells = - row.findElements(By.cssSelector("td")); - - if (cells.isEmpty()) { - continue; - } - - Map rowMap = - new LinkedHashMap<>(); - - for (int i = 0; i < headers.size(); i++) { - String header = headers.get(i); - - String cellText = i < cells.size() - ? cells.get(i).getText().trim() - : ""; - - rowMap.put(header, cellText); - } - - result.add(rowMap); - } - - return result; - } - private static Locator semantic(Kind kind, String value) { Locator l = new Locator(kind); l.semanticValue = value; @@ -398,6 +306,124 @@ public List elements() { .collect(Collectors.toList()); } + /** + * Resolves this locator to a {@code
- if (headerCells.isEmpty()) { - for (WebElement row : allRows) { - List thCells = - row.findElements(By.cssSelector("th")); - - if (!thCells.isEmpty()) { - headerRow = row; - headerCells = thCells; - break; - } - } - } - - // 3. No at all -> use the first
} and returns its data rows as + * header-keyed maps — one map per row, keys in column order. + * + *

Headers are taken from the first {@code

} row that has + * {@code } anywhere in the + * table that has {@code }. Whichever row supplies the headers is never + * returned as data. + * + *

All text is trimmed. A row with fewer {@code

+ List theadRows = table.findElements(By.cssSelector("thead tr")); + + if (!theadRows.isEmpty()) { + List theadHeaders = + theadRows.get(0).findElements(By.cssSelector("th")); + + if (!theadHeaders.isEmpty()) { + headerRow = theadRows.get(0); + headerCells = theadHeaders; + } + } + + // 2. No header -> find the first containing as the header + if (headerCells.isEmpty()) { + headerRow = allRows.get(0); + headerCells = headerRow.findElements(By.cssSelector("td")); + } + + // Convert header WebElements into Strings + List headers = headerCells.stream() + .map(WebElement::getText) + .map(String::trim) + .collect(Collectors.toList()); + + if (headers.isEmpty()) { + return new ArrayList<>(); + } + + List> result = new ArrayList<>(); + + // Convert each data row into a Map + for (WebElement row : allRows) { + + // Do not treat the header row as data + if (row.equals(headerRow)) { + continue; + } + + List cells = + row.findElements(By.cssSelector("td")); + + if (cells.isEmpty()) { + continue; + } + + Map rowMap = + new LinkedHashMap<>(); + + for (int i = 0; i < headers.size(); i++) { + String header = headers.get(i); + + String cellText = i < cells.size() + ? cells.get(i).getText().trim() + : ""; + + rowMap.put(header, cellText); + } + + result.add(rowMap); + } + + return result; + } + // ------------------------------------------------------------------ // Internal resolution // ------------------------------------------------------------------
} cells; failing that, the first {@code
} cells; failing that, the {@code } cells of + * the first {@code
} cells than there are + * headers is padded with {@code ""}; cells beyond the last header are + * dropped. Rows with no {@code } at all — {@code }-only group + * headings, for instance — are skipped. The result is empty when the table + * has no rows, or when no headers could be derived. + * + *

Like every terminal action this waits for the table to be visible, and + * throws {@link LocatorException} if it never matches. Rows and cells are + * matched by descendant search, so a nested table's rows are read as part of + * the outer table. + * + *

+     * List<Map<String, String>> data = $("table#results").rows();
+     * assertEquals(data.get(0).get("Status"), "Active");
+     * 
+ */ + public List> rows() { + WebElement table = element(); + + List allRows = table.findElements(By.cssSelector("tr")); + + // Empty table + if (allRows.isEmpty()) { + return new ArrayList<>(); + } + + WebElement headerRow = null; + List headerCells = new ArrayList<>(); + + // 1. Try to find header cells inside
+ if (headerCells.isEmpty()) { + for (WebElement row : allRows) { + List thCells = + row.findElements(By.cssSelector("th")); + + if (!thCells.isEmpty()) { + headerRow = row; + headerCells = thCells; + break; + } + } + } + + // 3. No at all -> use the first