diff --git a/CHANGELOG.md b/CHANGELOG.md index 568322f..290336a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs-site/docs/changelog.md b/docs-site/docs/changelog.md index 0e75984..575a1b4 100644 --- a/docs-site/docs/changelog.md +++ b/docs-site/docs/changelog.md @@ -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 diff --git a/src/main/java/com/seleniumboot/locator/Locator.java b/src/main/java/com/seleniumboot/locator/Locator.java index dc77c00..7d172dc 100644 --- a/src/main/java/com/seleniumboot/locator/Locator.java +++ b/src/main/java/com/seleniumboot/locator/Locator.java @@ -1151,6 +1151,8 @@ private T whenReady(Predicate ready, Function 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) { @@ -1158,6 +1160,7 @@ private T whenReady(Predicate ready, Function act } throw e; } + ExecutionMetrics.recordLocatorUse(SeleniumBootContext.getCurrentTestId(), describe(), true); if (debug.getSlowMoMs() > 0) slowMo(debug.getSlowMoMs()); return result.get(); } diff --git a/src/main/java/com/seleniumboot/metrics/ExecutionMetrics.java b/src/main/java/com/seleniumboot/metrics/ExecutionMetrics.java index e083efd..ebe859a 100644 --- a/src/main/java/com/seleniumboot/metrics/ExecutionMetrics.java +++ b/src/main/java/com/seleniumboot/metrics/ExecutionMetrics.java @@ -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(); diff --git a/src/main/java/com/seleniumboot/metrics/TestTiming.java b/src/main/java/com/seleniumboot/metrics/TestTiming.java index f3f66b8..0c6e9ae 100644 --- a/src/main/java/com/seleniumboot/metrics/TestTiming.java +++ b/src/main/java/com/seleniumboot/metrics/TestTiming.java @@ -141,7 +141,17 @@ public java.util.List 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 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 getLocatorUses() { + return java.util.Collections.unmodifiableList(locatorUses); + } public java.util.List getSteps() { return java.util.Collections.unmodifiableList(steps); } diff --git a/src/main/java/com/seleniumboot/steps/LocatorUse.java b/src/main/java/com/seleniumboot/steps/LocatorUse.java new file mode 100644 index 0000000..3170e1e --- /dev/null +++ b/src/main/java/com/seleniumboot/steps/LocatorUse.java @@ -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; } +} diff --git a/src/main/java/com/seleniumboot/tracing/TraceRecorder.java b/src/main/java/com/seleniumboot/tracing/TraceRecorder.java index 48d7ea6..aa908d8 100644 --- a/src/main/java/com/seleniumboot/tracing/TraceRecorder.java +++ b/src/main/java/com/seleniumboot/tracing/TraceRecorder.java @@ -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; @@ -29,6 +30,7 @@ *
  • Per-step screenshots — click a step to reveal its screenshot
  • *
  • Final-state screenshot — taken at the moment of failure
  • *
  • Browser console errors — when {@code browser.captureConsoleErrors} is on
  • + *
  • Locators used — each terminal {@code Locator} action, grouped under the step it ran in
  • *
  • Error message + full stack trace
  • * * @@ -208,6 +210,32 @@ public static String buildHtml(TestTiming timing, String testName, String finalS sb.append("\n\n"); } + // ── Locators used, grouped under the step they ran in ── + List uses = timing.getLocatorUses(); + if (!uses.isEmpty()) { + sb.append("
    \n"); + sb.append("
    Locators Used"); + sb.append(" (").append(uses.size()).append(")
    \n"); + sb.append("
    \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("
    ").append(escHtml(label)).append("
    \n"); + } + sb.append("
    ") + .append("").append(escHtml(u.getLocator())).append("") + .append("+").append(u.getOffsetMs()).append("ms") + .append(u.isFound() ? "" : " · not found").append("
    \n"); + } + sb.append("
    \n
    \n"); + } + // ── Error section ── if (errorMsg != null || stackTrace != null) { sb.append("
    \n"); @@ -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" + diff --git a/src/test/java/com/seleniumboot/unit/LocatorDebugTest.java b/src/test/java/com/seleniumboot/unit/LocatorDebugTest.java index a2df99e..ea7debb 100644 --- a/src/test/java/com/seleniumboot/unit/LocatorDebugTest.java +++ b/src/test/java/com/seleniumboot/unit/LocatorDebugTest.java @@ -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; @@ -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()); + } } diff --git a/src/test/java/com/seleniumboot/unit/TraceRecorderTest.java b/src/test/java/com/seleniumboot/unit/TraceRecorderTest.java index 483f48f..1d967f2 100644 --- a/src/test/java/com/seleniumboot/unit/TraceRecorderTest.java +++ b/src/test/java/com/seleniumboot/unit/TraceRecorderTest.java @@ -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; @@ -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("User")"); + int s2 = section.indexOf("Submit form"), l2 = section.indexOf("getByRole(BUTTON).withName("Go")"); + 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");