Skip to main content

Playwright Traces per Step

Browser tests fail for reasons a stack trace rarely explains. This example wires Playwright tracing to SpecBinder's execution boundaries so that every Gherkin step gets its own self-contained trace zip — screenshots, DOM snapshots, network, console — and the path to that zip is stamped onto the step in the JSON execution report.

The payoff: open the spec in IntelliJ, click a step, and watch that step replay.

Source: examples/going-further/example-8 on GitHub.

What this demonstrates

  • The ExecutionBoundaryListener SPI — observing feature, rule, scenario and step boundaries as they happen
  • Playwright's chunked tracing: one recording per feature, sliced into one zip per step
  • Recording arbitrary data onto the in-flight step via SpecBinderReporter.recordPublishedEntry
  • The reserved specbinder.playwright.trace key that lets the IntelliJ plugin find a step's trace
  • Scenario Outline rows getting a folder each, so rows don't overwrite one another

How it fits together

SpecBinderReporter  ──raises──>  ExecutionBoundaryListener

│ scenarioStarted / stepStarted / stepFinished

PlaywrightTraceListener
│ │
startChunk/ │ │ recordPublishedEntry(
stopChunk ─────┘ └── "specbinder.playwright.trace" → path)
│ │
▼ ▼
one zip per step the step's entry in the JSON report

Activation

Two test dependencies — the reporter (which supplies both the JSON report and the SPI) and Playwright itself:

<dependency>
<groupId>dev.specbinder</groupId>
<artifactId>execution-reporter</artifactId>
<version>2026.47.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.microsoft.playwright</groupId>
<artifactId>playwright</artifactId>
<version>1.54.0</version>
<scope>test</scope>
</dependency>

The listener

This is the whole integration. It implements ExecutionBoundaryListener, which the reporter raises in strict nesting order — featureStarted → scenarioStarted → stepStarted / stepFinished — and whose methods are all defaulted, so you override only what you need:

public final class PlaywrightTraceListener implements ExecutionBoundaryListener {

private final BrowserContext browserContext;
private final Path baseDir;

private String scenarioFolder;
private int stepOrdinal;
private Path currentChunk;

@Override
public void scenarioStarted(ScenarioBoundary scenario) {
String folder = scenario.testMethodName() != null ? scenario.testMethodName() : "scenario";
if (scenario.exampleRowIndex() != null) {
folder += String.format("_ex_%02d", scenario.exampleRowIndex());
}
scenarioFolder = folder;
stepOrdinal = 0;
}

@Override
public void stepStarted(StepBoundary step) {
currentChunk = baseDir.resolve(scenarioFolder)
.resolve(String.format("%02d_%s.zip", ++stepOrdinal, step.methodName()));

SpecBinderReporter.recordPublishedEntry(
Map.of(PLAYWRIGHT_TRACE_ENTRY_KEY, currentChunk.toString()), Instant.now());

browserContext.tracing().startChunk();
}

@Override
public void stepFinished(StepBoundary step) {
Path chunk = currentChunk;
currentChunk = null;
browserContext.tracing().stopChunk(new Tracing.StopChunkOptions().setPath(chunk));
}
}

Three things in there are worth calling out.

Chunked tracing. tracing().start(...) runs once for the whole feature. Each step is then a startChunk() / stopChunk(setPath(zip)) pair inside that one recording. Each zip is a complete, independently-openable trace rather than a slice you have to scrub to.

The path is recorded at stepStarted, not stepFinished. recordPublishedEntry attaches to whichever step is in flight, and the reporter has already cleared that by the time the step finishes. So the path goes in before the zip exists — stopChunk writes the file moments later, long before anything reads the report.

PLAYWRIGHT_TRACE_ENTRY_KEY is not yours to invent. It's a constant on ExecutionBoundaryListener ("specbinder.playwright.trace"), defined in the reporter module precisely so that this producer and its consumers — notably the IntelliJ plugin — agree on one name instead of duplicating a magic string across repositories.

Wiring the browser

The rest is ordinary JUnit. An extension owns the browser, starts the trace, and registers the listener:

public class PlaywrightTracing implements BeforeAllCallback, AfterAllCallback {

@Override
public void beforeAll(ExtensionContext context) {
playwright = Playwright.create();
browser = playwright.chromium().launch();
browserContext = browser.newContext();
page = browserContext.newPage();

browserContext.tracing().start(new Tracing.StartOptions()
.setScreenshots(true)
.setSnapshots(true)
.setSources(true));

traceListener = new PlaywrightTraceListener(browserContext, Path.of("target", "playwright-traces"));
SpecBinderReporter.addBoundaryListener(traceListener);
}

@Override
public void afterAll(ExtensionContext context) {
SpecBinderReporter.removeBoundaryListener(traceListener);
browserContext.tracing().stop(); // no path: the chunks already own the output
// …close page, context, browser, playwright
}
}

Both extensions go on the marker class, and because @ExtendWith is @Inherited, that one annotation covers every generated …Scenarios subclass JUnit runs:

@Gherkin2JUnit("specs/CartTrace.specb")
@Gherkin2JUnitOptions(shouldBeAbstract = false)
@ExtendWith({SpecBinderReporter.class, PlaywrightTracing.class})
public abstract class CartTraceFeature {

public void iOpenTheCartPage() {
page().navigate(CART_PAGE);
}

public void iAdd$p1ToTheCart(String item) {
page().click("[data-add='" + item + "']");
}

public void theCartTotalShouldBe$p1(Double expected) {
assertThat(page().locator("#total")).hasText(String.format("%.2f", expected));
}
}

Running it

cd examples/going-further/example-8
mvn test

The steps drive a small bundled cart.html, so there's no server to start and nothing to reach over the network. If Playwright reports a missing browser:

mvn exec:java -e -Dexec.mainClass=com.microsoft.playwright.CLI -Dexec.args="install chromium"

One scenario fails on purpose — a step asserting the wrong total — because the trace of a red step is the interesting one.

What lands on disk

target/
├── playwright-traces/
│ ├── scenario_2/
│ │ ├── 01_iOpenTheCartPage.zip
│ │ ├── 02_iAdd$p1ToTheCart.zip
│ │ └── 03_theCartTotalShouldBe$p1.zip
│ ├── scenario_6_ex_01/ ← Scenario Outline rows get a folder per row
│ └── …
└── specbinder-reports/specs/CartTrace.specb.json

25 zips for 8 scenarios. Open any one on its own:

npx playwright show-trace target/playwright-traces/scenario_5/03_theCartTotalShouldBe\$p1.zip

Chunk paths come straight from ScenarioBoundary. testMethodName() is already rule-qualified and unique within the class (e.g. rule_1_scenario_2), so it needs no separate rule segment. Outline rows share one method name, so exampleRowIndex() is appended as _ex_NN.

In the report

Each step carries its trace next to its outcome. The failing step, in full:

{
"methodName" : "theCartTotalShouldBe$p1",
"text" : "Then the cart total should be \"99.99\"",
"arguments" : [ { "type" : "simple", "value" : 99.99 } ],
"status" : "failed",
"error" : {
"message" : "Locator expected to have text: 99.99\nReceived: 0.75",
"expected" : "99.99",
"actual" : "0.75"
},
"publishedReporterEntries" : [ {
"values" : {
"specbinder.playwright.trace" : "target/playwright-traces/scenario_5/03_theCartTotalShouldBe$p1.zip"
}
} ]
}

The verbatim text and typed arguments come free with emitScenarioHash, which is on by default — see Execution Reporter.

Reading it in the IDE

This is the half that makes the wiring worth it. With the SpecBinder IntelliJ plugin installed, opening the spec after a run puts a collapsed row under each traced step:

✓ Then the cart total should be "99.99"
│ ▸ Playwright trace — 03_theCartTotalShouldBe$p1.zip ↗

Clicking the file name expands Playwright's viewer inline in the editor; the opens the same trace full-size in Playwright's hosted viewer, served over a loopback address so the recording never leaves your machine.

The panels are off by default — switch them on under Settings → Tools → SpecBinder → Execution results. See Playwright traces in the plugin docs for the full behaviour.

Running in parallel

PlaywrightTraceListener keeps its scenario folder, step counter and in-flight chunk in plain fields, which assumes one feature recording at a time. That holds for this example and for any sequential suite.

If you run test classes concurrently, move that state into a ThreadLocal scoped per thread — otherwise chunks from different features interleave into the wrong trace. The boundary callbacks fire on whichever thread is executing the test, and the reporter deliberately does not serialise them across threads.