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 @@ -13,6 +13,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/).

- **Network mocking on Firefox via WebDriver BiDi** — `networkMock().stub(...)` now uses WebDriver BiDi network interception under the hood when supported by the driver (including Firefox and BiDi-enabled drivers), falling back to Chrome DevTools Protocol (CDP) for Chromium-based drivers. The existing stubbing API (`returnJson`, `returnBody`, `returnStatus`, `delay`, `clear`) works identically across Chrome and Firefox. (#196)

- **Trace shows the locators each step used** — every terminal `Locator` action (click, type, getText, …) is now recorded with its time offset and listed in the trace file under "Locators Used", grouped beneath the step it ran in. A lookup that timed out is marked "not found". Collected automatically, no config. (#130)

---

### v3.9.0 — 2026-10-21
Expand Down
2 changes: 2 additions & 0 deletions docs-site/docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ All notable changes to Selenium Boot are documented here.

- **Network mocking on Firefox via WebDriver BiDi** — `networkMock().stub(...)` now uses WebDriver BiDi network interception under the hood when supported by the driver (including Firefox and BiDi-enabled drivers), falling back to Chrome DevTools Protocol (CDP) for Chromium-based drivers. The existing stubbing API (`returnJson`, `returnBody`, `returnStatus`, `delay`, `clear`) works identically across Chrome and Firefox. (#196)

- **Trace shows the locators each step used** — every terminal `Locator` action (click, type, getText, …) is now recorded with its time offset and listed in the trace file under "Locators Used", grouped beneath the step it ran in. A lookup that timed out is marked "not found". Collected automatically, no config. (#130)

---

## [3.9.0] — 2026-10-21
Expand Down
3 changes: 3 additions & 0 deletions src/main/java/com/seleniumboot/locator/Locator.java
Original file line number Diff line number Diff line change
Expand Up @@ -1151,13 +1151,16 @@ private <T> T whenReady(Predicate<WebElement> ready, Function<WebElement, T> act
} catch (TimeoutException e) {
ExecutionMetrics.recordFailingLocator(
SeleniumBootContext.getCurrentTestId(), describe());
ExecutionMetrics.recordLocatorUse(
SeleniumBootContext.getCurrentTestId(), describe(), false);
// A locator that never matched still surfaces as LocatorException, as it did before
// the wait existed, so callers catching it keep working.
if (e.getCause() instanceof LocatorException le) {
throw new LocatorException(le.getMessage() + " (waited " + timeout + "s)", e);
}
throw e;
}
ExecutionMetrics.recordLocatorUse(SeleniumBootContext.getCurrentTestId(), describe(), true);
if (debug.getSlowMoMs() > 0) slowMo(debug.getSlowMoMs());
return result.get();
}
Expand Down
9 changes: 9 additions & 0 deletions src/main/java/com/seleniumboot/metrics/ExecutionMetrics.java
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,15 @@ public static void recordStep(String testId, com.seleniumboot.steps.StepRecord s
.addStep(step);
}

public static void recordLocatorUse(String testId, String locator, boolean found) {
if (testId == null || locator == null) return;
long start = getTestStartTime(testId);
long offsetMs = start > 0 ? System.currentTimeMillis() - start : 0L;
TIMINGS.computeIfAbsent(testId,
id -> new TestTiming(id, Thread.currentThread().getName()))
.addLocatorUse(new com.seleniumboot.steps.LocatorUse(locator, offsetMs, found));
}

public static void clearSteps(String testId) {
TestTiming t = TIMINGS.get(testId);
if (t != null) t.clearSteps();
Expand Down
12 changes: 11 additions & 1 deletion src/main/java/com/seleniumboot/metrics/TestTiming.java
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,17 @@ public java.util.List<String> getConsoleErrors() {
new java.util.concurrent.CopyOnWriteArrayList<>();

public void addStep(com.seleniumboot.steps.StepRecord step) { steps.add(step); }
public void clearSteps() { steps.clear(); }
public void clearSteps() { steps.clear(); locatorUses.clear(); }

private static final int MAX_LOCATOR_USES = 1000;
private final java.util.List<com.seleniumboot.steps.LocatorUse> locatorUses =
new java.util.concurrent.CopyOnWriteArrayList<>();
public void addLocatorUse(com.seleniumboot.steps.LocatorUse use) {
if (locatorUses.size() < MAX_LOCATOR_USES) locatorUses.add(use);
}
public java.util.List<com.seleniumboot.steps.LocatorUse> getLocatorUses() {
return java.util.Collections.unmodifiableList(locatorUses);
}
public java.util.List<com.seleniumboot.steps.StepRecord> getSteps() {
return java.util.Collections.unmodifiableList(steps);
}
Expand Down
19 changes: 19 additions & 0 deletions src/main/java/com/seleniumboot/steps/LocatorUse.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.seleniumboot.steps;

/** One {@code Locator} lookup made by a terminal action, kept so the trace can show it under its step. */
public class LocatorUse {

private final String locator;
private final long offsetMs;
private final boolean found;

public LocatorUse(String locator, long offsetMs, boolean found) {
this.locator = locator;
this.offsetMs = offsetMs;
this.found = found;
}

public String getLocator() { return locator; }
public long getOffsetMs() { return offsetMs; }
public boolean isFound() { return found; }
}
35 changes: 35 additions & 0 deletions src/main/java/com/seleniumboot/tracing/TraceRecorder.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import com.seleniumboot.metrics.ExecutionMetrics;
import com.seleniumboot.metrics.TestTiming;
import com.seleniumboot.reporting.ScreenshotManager;
import com.seleniumboot.steps.LocatorUse;
import com.seleniumboot.steps.StepRecord;
import org.openqa.selenium.OutputType;
import org.openqa.selenium.TakesScreenshot;
Expand All @@ -29,6 +30,7 @@
* <li>Per-step screenshots — click a step to reveal its screenshot</li>
* <li>Final-state screenshot — taken at the moment of failure</li>
* <li>Browser console errors — when {@code browser.captureConsoleErrors} is on</li>
* <li>Locators used — each terminal {@code Locator} action, grouped under the step it ran in</li>
* <li>Error message + full stack trace</li>
* </ul>
*
Expand Down Expand Up @@ -208,6 +210,32 @@ public static String buildHtml(TestTiming timing, String testName, String finalS
sb.append("</pre>\n</div>\n");
}

// ── Locators used, grouped under the step they ran in ──
List<LocatorUse> uses = timing.getLocatorUses();
if (!uses.isEmpty()) {
sb.append("<div class=\"section\">\n");
sb.append(" <div class=\"section-title\">Locators Used");
sb.append(" <span class=\"step-count\">(").append(uses.size()).append(")</span></div>\n");
sb.append(" <div class=\"locator-list\">\n");
int current = -2;
for (LocatorUse u : uses) {
int owner = -1;
for (int i = 0; i < steps.size(); i++) {
if (steps.get(i).getOffsetMs() <= u.getOffsetMs()) owner = i;
}
if (owner != current) {
current = owner;
String label = owner < 0 ? "Before first step" : steps.get(owner).getName();
sb.append(" <div class=\"locator-step\">").append(escHtml(label)).append("</div>\n");
}
sb.append(" <div class=\"locator-row").append(u.isFound() ? "" : " locator-fail").append("\">")
.append("<code>").append(escHtml(u.getLocator())).append("</code>")
.append("<span class=\"step-meta\">+").append(u.getOffsetMs()).append("ms")
.append(u.isFound() ? "" : " &middot; not found").append("</span></div>\n");
}
sb.append(" </div>\n</div>\n");
}

// ── Error section ──
if (errorMsg != null || stackTrace != null) {
sb.append("<div class=\"section error-section\">\n");
Expand Down Expand Up @@ -320,6 +348,13 @@ private static String escJs(String s) {
".final-screenshot-wrap { padding: 16px 20px; }\n" +
".final-screenshot { max-width: 100%; border-radius: 4px;\n" +
" border: 1px solid #30363d; display: block; }\n" +
".locator-list { padding: 8px 20px 14px; }\n" +
".locator-step { margin-top: 10px; color: #e6edf3; font-weight: 600; font-size: 13px; }\n" +
".locator-row { display: flex; justify-content: space-between; gap: 12px; padding: 3px 0 3px 12px;\n" +
" border-left: 2px solid #30363d; font-size: 12px; }\n" +
".locator-row code { font-family: 'SFMono-Regular', Consolas, monospace; color: #c9d1d9; word-break: break-all; }\n" +
".locator-fail { border-left-color: #f85149; }\n" +
".locator-fail code { color: #f85149; }\n" +
".error-section { }\n" +
".error-title { color: #f85149 !important; }\n" +
".error-msg { padding: 14px 20px; color: #f85149; font-weight: 600;\n" +
Expand Down
15 changes: 15 additions & 0 deletions src/test/java/com/seleniumboot/unit/LocatorDebugTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@
import org.testng.annotations.BeforeMethod;
import org.testng.annotations.Test;

import com.seleniumboot.metrics.ExecutionMetrics;
import com.seleniumboot.metrics.TestTiming;

import java.util.List;

import static org.mockito.ArgumentMatchers.any;
Expand Down Expand Up @@ -118,4 +121,16 @@ public void negativeSlowMo_clampsToZero() {
config.getDebug().setSlowMoMs(-5);
assertEquals(config.getDebug().getSlowMoMs(), 0L);
}

@Test
public void terminalAction_recordsTheLocatorUsedForTheTrace() {
contextMock.when(SeleniumBootContext::getCurrentTestId).thenReturn("loc.use.test");
ExecutionMetrics.markStart("loc.use.test");
Locator.of(By.id("go")).click();

TestTiming t = ExecutionMetrics.getTiming("loc.use.test");
assertEquals(t.getLocatorUses().size(), 1);
assertTrue(t.getLocatorUses().get(0).getLocator().contains("go"));
assertTrue(t.getLocatorUses().get(0).isFound());
}
}
32 changes: 32 additions & 0 deletions src/test/java/com/seleniumboot/unit/TraceRecorderTest.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.seleniumboot.unit;

import com.seleniumboot.metrics.TestTiming;
import com.seleniumboot.steps.LocatorUse;
import com.seleniumboot.steps.StepRecord;
import com.seleniumboot.tracing.TraceRecorder;
import org.testng.Assert;
Expand Down Expand Up @@ -203,6 +204,37 @@ public void buildHtml_omitsConsoleSection_whenNoErrors() {
Assert.assertFalse(html.contains("Console Errors"));
}

@Test
public void buildHtml_groupsLocatorsUnderTheStepTheyRanIn() {
TestTiming t = timing("locTest");
t.addStep(new StepRecord("Open login", 0, "INFO", null));
t.addStep(new StepRecord("Submit form", 500, "INFO", null));
t.addLocatorUse(new LocatorUse("getByLabel(\"User\")", 100, true));
t.addLocatorUse(new LocatorUse("getByRole(BUTTON).withName(\"Go\")", 600, false));
String html = TraceRecorder.buildHtml(t, "locTest", null);
int open = html.indexOf("Locators Used");
Assert.assertTrue(open > 0, "locators section expected");
String section = html.substring(open);
int s1 = section.indexOf("Open login"), l1 = section.indexOf("getByLabel(&quot;User&quot;)");
int s2 = section.indexOf("Submit form"), l2 = section.indexOf("getByRole(BUTTON).withName(&quot;Go&quot;)");
Assert.assertTrue(s1 >= 0 && s1 < l1 && l1 < s2 && s2 < l2, section);
Assert.assertTrue(section.contains("locator-fail"), "failed lookup must be marked");
}

@Test
public void buildHtml_locatorBeforeFirstStep_isListedUnderPreamble() {
TestTiming t = timing("preTest");
t.addStep(new StepRecord("Later step", 400, "INFO", null));
t.addLocatorUse(new LocatorUse("css(#a)", 10, true));
String html = TraceRecorder.buildHtml(t, "preTest", null);
Assert.assertTrue(html.contains("Before first step"));
}

@Test
public void buildHtml_omitsLocatorSection_whenNoneRecorded() {
Assert.assertFalse(TraceRecorder.buildHtml(timing("noLoc"), "noLoc", null).contains("Locators Used"));
}

private static TestTiming timing(String testName) {
TestTiming t = new TestTiming(testName, "test-thread");
t.setStatus("FAILED");
Expand Down
Loading