AgentReport.java
package org.vaadin.addons.dramafinder.agent;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Locale;
import com.microsoft.playwright.Page;
/**
* Writes agent-oriented verification artifacts for a single test into a report
* directory ({@code target/agent-report/} for Maven, {@code build/agent-report/}
* for Gradle).
* <p>
* It produces two kinds of output:
* <ul>
* <li>deterministic, numbered screenshots via {@link #shot(String)} at key
* interaction points ({@code 01-login.png}, {@code 02-order-list.png}, …);</li>
* <li>a failure bundle via {@link #captureFailure(Throwable)} — the assertion
* message and a trimmed stack trace ({@code failure.txt}), a screenshot of
* the page at failure time ({@code failure.png}), and a semantic
* {@link ComponentSnapshot component snapshot}
* ({@code component-snapshot.txt}).</li>
* </ul>
* A far cheaper agent loop than driving Playwright MCP interactively: one run
* batches every action and screenshot, then the agent reads a terse report.
*/
public class AgentReport {
private static final int MAX_STACK_FRAMES = 15;
private final Page page;
private final Path directory;
private int shotCounter = 0;
/**
* Create a report writing into the build-tool default directory.
*
* @param page the Playwright page to screenshot and inspect
*/
public AgentReport(Page page) {
this(page, defaultReportDirectory());
}
/**
* Create a report writing into the given directory.
*
* @param page the Playwright page to screenshot and inspect
* @param directory the report output directory
*/
public AgentReport(Page page, Path directory) {
this.page = page;
this.directory = directory;
}
/**
* Resolve the default report directory for the current build tool:
* {@code build/agent-report} when a Gradle build file is present, otherwise
* {@code target/agent-report} (Maven).
*
* @return the resolved report directory (not created)
*/
public static Path defaultReportDirectory() {
boolean gradle = Files.exists(Path.of("build.gradle"))
|| Files.exists(Path.of("build.gradle.kts"))
|| Files.exists(Path.of("settings.gradle"))
|| Files.exists(Path.of("settings.gradle.kts"));
Path base = gradle ? Path.of("build") : Path.of("target");
return base.resolve("agent-report");
}
/**
* The directory this report writes to.
*
* @return the report directory
*/
public Path directory() {
return directory;
}
/**
* Capture a numbered screenshot of the current page state.
* <p>
* The file name is {@code NN-<name>.png}, where {@code NN} is a
* zero-padded, incrementing counter, giving deterministic ordering that
* matches the sequence of interaction points in the test.
*
* @param name a short descriptive name for the interaction point
* @return the written screenshot path
*/
public synchronized Path shot(String name) {
ensureDirectory();
String fileName = String.format(Locale.ROOT, "%02d-%s.png",
++shotCounter, sanitize(name));
Path file = directory.resolve(fileName);
page.screenshot(new Page.ScreenshotOptions().setPath(file));
return file;
}
/**
* Write the failure bundle for a failed test: {@code failure.txt} (message
* + trimmed stack trace + page URL), {@code failure.png} (full-page
* screenshot), and {@code component-snapshot.txt} (semantic snapshot).
* <p>
* Best-effort: a failure to capture the screenshot or snapshot (e.g. the
* page is already closed) never masks the original test failure.
*
* @param error the failure cause (may be {@code null})
*/
public void captureFailure(Throwable error) {
ensureDirectory();
StringBuilder txt = new StringBuilder();
txt.append("URL: ").append(safeUrl()).append(System.lineSeparator());
txt.append(System.lineSeparator());
txt.append(error == null ? "Test failed (no throwable)" : error.toString());
txt.append(System.lineSeparator()).append(System.lineSeparator());
txt.append(trimmedStackTrace(error));
write(directory.resolve("failure.txt"), txt.toString());
try {
page.screenshot(new Page.ScreenshotOptions()
.setPath(directory.resolve("failure.png"))
.setFullPage(true));
} catch (RuntimeException e) {
write(directory.resolve("failure.png.txt"),
"Screenshot capture failed: " + e.getMessage());
}
try {
String snapshot = ComponentSnapshot.capture(page);
write(directory.resolve("component-snapshot.txt"),
snapshot.isEmpty() ? "(no Vaadin components found)" : snapshot);
} catch (RuntimeException e) {
write(directory.resolve("component-snapshot.txt"),
"Component snapshot failed: " + e.getMessage());
}
}
private void ensureDirectory() {
try {
Files.createDirectories(directory);
} catch (IOException e) {
throw new UncheckedIOException(
"Could not create report directory " + directory, e);
}
}
private void write(Path file, String content) {
try {
Files.writeString(file, content, StandardCharsets.UTF_8);
} catch (IOException e) {
throw new UncheckedIOException("Could not write " + file, e);
}
}
private String safeUrl() {
try {
return page.url();
} catch (RuntimeException e) {
return "(unknown)";
}
}
private static String trimmedStackTrace(Throwable error) {
if (error == null) {
return "";
}
StringBuilder sb = new StringBuilder();
StackTraceElement[] frames = error.getStackTrace();
int shown = 0;
for (StackTraceElement frame : frames) {
if (shown >= MAX_STACK_FRAMES) {
sb.append("\t... ").append(frames.length - shown)
.append(" more").append(System.lineSeparator());
break;
}
String cls = frame.getClassName();
// Drop reflective/JUnit framework noise; keep test + library frames.
if (cls.startsWith("java.")
|| cls.startsWith("jdk.")
|| cls.startsWith("sun.")
|| cls.startsWith("org.junit.")
|| cls.startsWith("org.springframework.")) {
continue;
}
sb.append("\tat ").append(frame).append(System.lineSeparator());
shown++;
}
Throwable cause = error.getCause();
if (cause != null && cause != error) {
sb.append("Caused by: ").append(cause).append(System.lineSeparator());
}
return sb.toString();
}
private static String sanitize(String name) {
if (name == null || name.isBlank()) {
return "shot";
}
return name.trim().replaceAll("[^A-Za-z0-9._-]+", "-");
}
}