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

- **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)

- **Trace: console errors and failed requests under the step they happened in** — console errors (Chrome) now carry their timestamp and are listed beneath the step they occurred in. New opt-in `tracing.captureNetwork: true` records requests that returned 4xx/5xx or failed outright (CDP on Chrome/Edge, WebDriver BiDi on Firefox) and lists them the same way, so a trace shows the `500` behind a failed step. Off by default; successful requests are never recorded and at most 200 are kept per test. (#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 @@ -17,6 +17,8 @@ All notable changes to Selenium Boot are documented here.

- **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)

- **Trace: console errors and failed requests under the step they happened in** — console errors (Chrome) now carry their timestamp and are listed beneath the step they occurred in. New opt-in `tracing.captureNetwork: true` records requests that returned 4xx/5xx or failed outright (CDP on Chrome/Edge, WebDriver BiDi on Firefox) and lists them the same way, so a trace shows the `500` behind a failed step. Off by default; successful requests are never recorded and at most 200 are kept per test. (#130)

---

## [3.9.0] — 2026-10-21
Expand Down
8 changes: 6 additions & 2 deletions docs-site/docs/guides/trace-viewer.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ Trace recording is configured in `selenium-boot.yml`. When `tracing.enabled` is
tracing:
enabled: true
captureOnPass: false # set true to generate traces for passing tests too
captureNetwork: false # set true to list failed requests (4xx/5xx, network errors) in the trace
```

When enabled, a self-contained HTML file is generated for each failed test at:
Expand All @@ -39,8 +40,11 @@ Each trace file is an independent, single-file HTML document featuring:
1. **Metadata header** — test method name, class, browser name, duration in milliseconds, status badge, and timestamp.
2. **Interactive step timeline** — every named step recorded via `StepLogger.step(...)` appears in the timeline with its offset. Steps that captured screenshots display a camera icon (📷) and open the interactive screenshot panel on click.
3. **Final-state screenshot** — full-browser screenshot captured automatically at the exact moment of test failure.
4. **Failure details** — highlighted error message and full scrollable stack trace.
5. **Zero CDN dependencies** — CSS styles and interactive JavaScript are completely inlined so traces can be viewed offline, archived, or shared without broken assets.
4. **Locators used** — each `Locator` action (click, type, getText, …) listed under the step it ran in; a lookup that timed out is marked "not found".
5. **Console errors** — with `browser.captureConsoleErrors: true`, JS errors are listed under the step they happened in (Chrome; other browsers list them without a step).
6. **Failed requests** — with `tracing.captureNetwork: true`, requests that returned 4xx/5xx or failed outright (method, URL, status or error) are listed under their step. Uses CDP on Chrome/Edge and WebDriver BiDi on Firefox; off by default because it keeps a listener open for the whole test. Successful requests are not recorded, and at most 200 are kept per test.
7. **Failure details** — highlighted error message and full scrollable stack trace.
8. **Zero CDN dependencies** — CSS styles and interactive JavaScript are completely inlined so traces can be viewed offline, archived, or shared without broken assets.

---

Expand Down
16 changes: 14 additions & 2 deletions src/main/java/com/seleniumboot/browser/ConsoleErrorCollector.java
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,10 @@ public static List<String> collect() {
try {
driver.manage().logs().get(LogType.BROWSER).getAll().stream()
.filter(e -> e.getLevel().intValue() >= Level.SEVERE.intValue())
.map(LogEntry::getMessage)
.forEach(errors::add);
.forEach(e -> {
errors.add(e.getMessage());
recordForTrace(e);
});
} catch (Exception ignored) {
// Firefox and some drivers don't support browser logs
}
Expand All @@ -80,6 +82,16 @@ public static List<String> collect() {
return Collections.unmodifiableList(errors);
}

/** Keeps the entry's timestamp so the trace can show it under the step it happened in. */
private static void recordForTrace(LogEntry e) {
try {
com.seleniumboot.metrics.ExecutionMetrics.recordConsoleEntry(
SeleniumBootContext.getCurrentTestId(), e.getMessage(), e.getTimestamp());
} catch (RuntimeException ignored) {
// trace detail only — never let it break console collection
}
}

/**
* Injects a JS console error shim into the current page.
* Call this after page load if using Firefox or browsers without WebDriver log support.
Expand Down
5 changes: 5 additions & 0 deletions src/main/java/com/seleniumboot/config/SeleniumBootConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -523,6 +523,7 @@ public static final class Tracing {
/** null = not set in selenium-boot.yml; resolved by {@link #isEffectivelyEnabled(boolean)}. */
private Boolean enabled = null;
private boolean captureOnPass = false;
private boolean captureNetwork = false;

/** The value set in selenium-boot.yml; false when unset. Prefer {@link #isEffectivelyEnabled()}. */
public boolean isEnabled() { return Boolean.TRUE.equals(enabled); }
Expand All @@ -534,6 +535,10 @@ public static final class Tracing {

public boolean isCaptureOnPass() { return captureOnPass; }
public void setCaptureOnPass(boolean v) { this.captureOnPass = v; }

/** Opt-in: listen for failed requests (4xx/5xx, network errors) for the whole test and list them in the trace. */
public boolean isCaptureNetwork() { return captureNetwork; }
public void setCaptureNetwork(boolean v) { this.captureNetwork = v; }
}

public static final class Visual {
Expand Down
1 change: 1 addition & 0 deletions src/main/java/com/seleniumboot/cucumber/CucumberHooks.java
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ public void beforeScenario(Scenario scenario) {
}
ExecutionMetrics.clearSteps(testId);
ExecutionMetrics.markStart(testId);
com.seleniumboot.tracing.NetworkFailureRecorder.startIfEnabled(testId);
ExecutionMetrics.recordTestClass(testId, featureTitle(scenario.getUri()));
ExecutionMetrics.recordDescription(testId, scenario.getName());
java.util.Collection<String> scenarioTags = scenario.getSourceTagNames();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ public void beforeEach(ExtensionContext context) {
SeleniumBootContext.setCurrentTestId(testId);
ExecutionMetrics.clearSteps(testId);
ExecutionMetrics.markStart(testId);
com.seleniumboot.tracing.NetworkFailureRecorder.startIfEnabled(testId);
ExecutionMetrics.recordTestClass(testId, context.getRequiredTestClass().getSimpleName());
ExecutionMetrics.recordDescription(testId, context.getDisplayName());
java.util.Set<String> tags = context.getTags();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ public void onTestStart(ITestResult result) {
SeleniumBootContext.setCurrentTestId(testId);
ExecutionMetrics.clearSteps(testId); // discard stale steps from prior retry attempt
ExecutionMetrics.markStart(testId);
com.seleniumboot.tracing.NetworkFailureRecorder.startIfEnabled(testId);
ExecutionMetrics.recordTestClass(testId, result.getTestClass().getRealClass().getSimpleName());
ExecutionMetrics.recordDescription(testId, result.getMethod().getDescription());
String[] groups = result.getMethod().getGroups();
Expand Down
21 changes: 21 additions & 0 deletions src/main/java/com/seleniumboot/metrics/ExecutionMetrics.java
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,26 @@ public static void recordLocatorUse(String testId, String locator, boolean found
.addLocatorUse(new com.seleniumboot.steps.LocatorUse(locator, offsetMs, found));
}

/** Stores a console error with its offset from test start; {@code timestampMs} is epoch millis. */
public static void recordConsoleEntry(String testId, String message, long timestampMs) {
if (testId == null || message == null) return;
long start = getTestStartTime(testId);
long offsetMs = start > 0 ? Math.max(0L, timestampMs - start) : 0L;
TIMINGS.computeIfAbsent(testId,
id -> new TestTiming(id, Thread.currentThread().getName()))
.addConsoleEntry(new com.seleniumboot.steps.ConsoleEntry(message, offsetMs));
}

/** Stores a failed request ({@code status} 0 plus {@code error} when no response arrived). */
public static void recordNetworkFailure(String testId, String method, String url, int status, String error) {
if (testId == null || url == null) return;
long start = getTestStartTime(testId);
long offsetMs = start > 0 ? System.currentTimeMillis() - start : 0L;
TIMINGS.computeIfAbsent(testId,
id -> new TestTiming(id, Thread.currentThread().getName()))
.addNetworkFailure(new com.seleniumboot.steps.NetworkFailure(method, url, status, error, offsetMs));
}

public static void clearSteps(String testId) {
TestTiming t = TIMINGS.get(testId);
if (t != null) t.clearSteps();
Expand Down Expand Up @@ -241,6 +261,7 @@ public static void recordStatus(String testId, String status) {
// ==========================================================

public static void markEnd(String testId) {
com.seleniumboot.tracing.NetworkFailureRecorder.stop(testId);

Long start = START_TIMES.remove(testId);
if (start == null) return;
Expand Down
22 changes: 21 additions & 1 deletion src/main/java/com/seleniumboot/metrics/TestTiming.java
Original file line number Diff line number Diff line change
Expand Up @@ -141,14 +141,34 @@ 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(); locatorUses.clear(); }
public void clearSteps() { steps.clear(); locatorUses.clear(); consoleEntries.clear(); networkFailures.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);
}

private static final int MAX_CONSOLE_ENTRIES = 500;
private static final int MAX_NETWORK_FAILURES = 200;
private final java.util.List<com.seleniumboot.steps.ConsoleEntry> consoleEntries =
new java.util.concurrent.CopyOnWriteArrayList<>();
private final java.util.List<com.seleniumboot.steps.NetworkFailure> networkFailures =
new java.util.concurrent.CopyOnWriteArrayList<>();
public void addConsoleEntry(com.seleniumboot.steps.ConsoleEntry e) {
if (consoleEntries.size() < MAX_CONSOLE_ENTRIES) consoleEntries.add(e);
}
public java.util.List<com.seleniumboot.steps.ConsoleEntry> getConsoleEntries() {
return java.util.Collections.unmodifiableList(consoleEntries);
}
public void addNetworkFailure(com.seleniumboot.steps.NetworkFailure f) {
if (networkFailures.size() < MAX_NETWORK_FAILURES) networkFailures.add(f);
}
public java.util.List<com.seleniumboot.steps.NetworkFailure> getNetworkFailures() {
return java.util.Collections.unmodifiableList(networkFailures);
}

public java.util.List<com.seleniumboot.steps.LocatorUse> getLocatorUses() {
return java.util.Collections.unmodifiableList(locatorUses);
}
Expand Down
16 changes: 16 additions & 0 deletions src/main/java/com/seleniumboot/steps/ConsoleEntry.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.seleniumboot.steps;

/** A browser console error with the time it was logged, so the trace can show it under its step. */
public class ConsoleEntry {

private final String message;
private final long offsetMs;

public ConsoleEntry(String message, long offsetMs) {
this.message = message;
this.offsetMs = offsetMs;
}

public String getMessage() { return message; }
public long getOffsetMs() { return offsetMs; }
}
26 changes: 26 additions & 0 deletions src/main/java/com/seleniumboot/steps/NetworkFailure.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.seleniumboot.steps;

/** A request that came back 4xx/5xx or failed outright, kept so the trace can show it under its step. */
public class NetworkFailure {

private final String method;
private final String url;
private final int status;
private final String error;
private final long offsetMs;

/** @param status HTTP status, or 0 when the request failed before any response; @param error failure text, else null */
public NetworkFailure(String method, String url, int status, String error, long offsetMs) {
this.method = method;
this.url = url;
this.status = status;
this.error = error;
this.offsetMs = offsetMs;
}

public String getMethod() { return method; }
public String getUrl() { return url; }
public int getStatus() { return status; }
public String getError() { return error; }
public long getOffsetMs() { return offsetMs; }
}
118 changes: 118 additions & 0 deletions src/main/java/com/seleniumboot/tracing/NetworkFailureRecorder.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package com.seleniumboot.tracing;

import com.seleniumboot.driver.DriverManager;
import com.seleniumboot.internal.SeleniumBootContext;
import com.seleniumboot.metrics.ExecutionMetrics;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.bidi.HasBiDi;
import org.openqa.selenium.bidi.module.Network;
import org.openqa.selenium.chromium.ChromiumDriver;
import org.openqa.selenium.devtools.DevTools;
import org.openqa.selenium.devtools.v153.network.model.LoadingFailed;
import org.openqa.selenium.devtools.v153.network.model.RequestWillBeSent;
import org.openqa.selenium.devtools.v153.network.model.ResponseReceived;

import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.logging.Level;
import java.util.logging.Logger;

/**
* Listens for failed requests (HTTP 4xx/5xx and network errors) for the length of a test so the
* trace can list them under the step they happened in. Opt-in via {@code tracing.captureNetwork}.
*
* <p>CDP on Chromium, WebDriver BiDi on anything else that supports it; a driver with neither is
* skipped silently. Never fails a test: every problem is logged and swallowed.
*
* <p>Started when a test starts, stopped from {@link ExecutionMetrics#markEnd(String)}, which every
* runner (TestNG, JUnit 5, Cucumber) calls when a test finishes.
*/
public final class NetworkFailureRecorder {

private static final Logger LOG = Logger.getLogger(NetworkFailureRecorder.class.getName());

private static final Map<String, AutoCloseable> ACTIVE = new ConcurrentHashMap<>();

private NetworkFailureRecorder() {}

/** Starts listening for the current test if {@code tracing.captureNetwork} (and tracing) is on. */
public static void startIfEnabled(String testId) {
try {
var tracing = SeleniumBootContext.getConfig().getTracing();
if (tracing == null || !tracing.isEffectivelyEnabled() || !tracing.isCaptureNetwork()) return;
start(testId, DriverManager.getDriver());
} catch (RuntimeException e) {
LOG.log(Level.FINE, "[NetworkFailureRecorder] not started: " + e.getMessage());
}
}

static void start(String testId, WebDriver driver) {
if (testId == null || driver == null) return;
stop(testId);
try {
AutoCloseable listener = null;
if (driver instanceof ChromiumDriver chromium) {
listener = listenCdp(testId, chromium);
} else if (driver instanceof HasBiDi) {
listener = listenBiDi(testId, driver);
}
if (listener != null) ACTIVE.put(testId, listener);
} catch (Exception | LinkageError e) {
LOG.warning("[NetworkFailureRecorder] could not start for " + testId + ": " + e.getMessage());
}
}

/** Stops listening for this test; safe to call when nothing was started. */
public static void stop(String testId) {
if (testId == null) return;
AutoCloseable listener = ACTIVE.remove(testId);
if (listener == null) return;
try {
listener.close();
} catch (Exception e) {
LOG.log(Level.FINE, "[NetworkFailureRecorder] close failed: " + e.getMessage());
}
}

private static AutoCloseable listenCdp(String testId, ChromiumDriver chromium) {
DevTools devTools = chromium.getDevTools();
devTools.createSession();
devTools.send(org.openqa.selenium.devtools.v153.network.Network.enable(
Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty()));
Map<String, String[]> seen = new ConcurrentHashMap<>(); // requestId (as text; RequestId has no value equality) -> {method, url}
devTools.addListener(org.openqa.selenium.devtools.v153.network.Network.requestWillBeSent(),
(RequestWillBeSent e) -> seen.put(e.getRequestId().toString(),
new String[]{e.getRequest().getMethod(), e.getRequest().getUrl()}));
devTools.addListener(org.openqa.selenium.devtools.v153.network.Network.responseReceived(),
(ResponseReceived e) -> {
int status = e.getResponse().getStatus().intValue();
if (status < 400) return;
String[] req = seen.get(e.getRequestId().toString());
ExecutionMetrics.recordNetworkFailure(testId, req != null ? req[0] : "GET",
e.getResponse().getUrl(), status, null);
});
devTools.addListener(org.openqa.selenium.devtools.v153.network.Network.loadingFailed(),
(LoadingFailed e) -> {
if (Boolean.TRUE.equals(e.getCanceled().orElse(false))) return; // aborted by navigation, not a failure
String[] req = seen.get(e.getRequestId().toString());
ExecutionMetrics.recordNetworkFailure(testId, req != null ? req[0] : "GET",
req != null ? req[1] : "(unknown)", 0, e.getErrorText());
});
return devTools::close;
}

private static AutoCloseable listenBiDi(String testId, WebDriver driver) {
Network network = new Network(driver);
network.onResponseCompleted(e -> {
int status = (int) e.getResponseData().getStatus();
if (status >= 400) {
ExecutionMetrics.recordNetworkFailure(testId, e.getRequest().getMethod(),
e.getResponseData().getUrl(), status, null);
}
});
network.onFetchError(e -> ExecutionMetrics.recordNetworkFailure(testId, e.getRequest().getMethod(),
e.getRequest().getUrl(), 0, e.getErrorText()));
return network;
}
}
Loading
Loading