Skip to content
Open
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
5 changes: 5 additions & 0 deletions allure-playwright/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,12 @@ dependencies {
api(project(":allure-java-commons"))
compileOnly("com.microsoft.playwright:playwright:$playwrightVersion")
compileOnly("org.aspectj:aspectjrt")
// Gson, not a new dependency in practice: com.microsoft.playwright:playwright already declares it
// (compile scope) in its own POM, so anyone with the real Playwright jar on their classpath already
// has it. Used to rewrite trace.stacks in TraceSourceSanitizer.
compileOnly("com.google.code.gson:gson")
testImplementation("com.microsoft.playwright:playwright:$playwrightVersion")
testImplementation("com.google.code.gson:gson")
testImplementation("org.assertj:assertj-core")
testImplementation("org.junit.jupiter:junit-jupiter-api")
testImplementation("org.slf4j:slf4j-simple")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.function.Supplier;

/**
Expand All @@ -54,6 +55,7 @@ public final class AllurePlaywright {
private static final String VIDEO = "Playwright video";
private static final String CONSOLE_MESSAGES = "Console messages";
private static final String PAGE_ERRORS = "Page errors";
private static final String PLAYWRIGHT_JAVA_SRC = "PLAYWRIGHT_JAVA_SRC";

private static final ThreadLocal<Boolean> SUPPRESS_ASPECT = new ThreadLocal<Boolean>() {
@Override
Expand Down Expand Up @@ -239,16 +241,32 @@ public static TraceSession startTracing(final String name, final BrowserContext
if (context == null) {
throw new IllegalArgumentException("context must not be null");
}
final boolean embedSources = shouldEmbedSources();
final Tracing.StartOptions options = new Tracing.StartOptions()
.setScreenshots(true)
.setSnapshots(true);
.setSnapshots(true)
.setSources(embedSources);
context.tracing().start(options);
final DefaultTraceSession traceSession = new DefaultTraceSession(context, defaultName(name, TRACE));
final DefaultTraceSession traceSession = new DefaultTraceSession(context, defaultName(name, TRACE), embedSources);
AllurePlaywrightRegistry.register(context);
AllurePlaywrightRegistry.register(traceSession);
return traceSession;
}

static boolean shouldEmbedSources() {
if (!AllurePlaywrightConfig.shouldEmbedTraceSources()) {
return false;
}
if (Objects.isNull(System.getenv(PLAYWRIGHT_JAVA_SRC))) {
LOGGER.warn(
"allure.playwright.trace.sources is enabled, but the {} environment variable is not set. Traces are not collected.",
PLAYWRIGHT_JAVA_SRC
);
return false;
}
return true;
}

static CloseArtifacts beforeClose(final Object target) {
final CloseArtifacts closeArtifacts = new CloseArtifacts();
if (target instanceof Page) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ final class AllurePlaywrightConfig {
static final String CLOSE_TRACE = "allure.playwright.close.trace";
static final String CLOSE_VIDEO = "allure.playwright.close.video";
static final String CLOSE_PAGE_LOGS = "allure.playwright.close.page-logs";
static final String TRACE_SOURCES = "allure.playwright.trace.sources";

private static final String ACTIONS = "actions";
private static final String ALL = "all";
Expand Down Expand Up @@ -79,6 +80,10 @@ static boolean shouldAttachClosePageLogs() {
return getBoolean(CLOSE_PAGE_LOGS, true);
}

static boolean shouldEmbedTraceSources() {
return getBoolean(TRACE_SOURCES, false);
}

private static boolean getBoolean(final String key, final boolean defaultValue) {
return Boolean.parseBoolean(getProperties().getProperty(key, Boolean.toString(defaultValue)));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,13 @@ final class DefaultTraceSession implements TraceSession {

private final BrowserContext context;
private final String name;
private final boolean isSourcesEmbedded;
private boolean stopped;

DefaultTraceSession(final BrowserContext context, final String name) {
DefaultTraceSession(final BrowserContext context, final String name, final boolean isSourcesEmbedded) {
this.context = context;
this.name = name;
this.isSourcesEmbedded = isSourcesEmbedded;
}

@Override
Expand Down Expand Up @@ -64,6 +66,9 @@ private void stop(final boolean attach) {
try {
trace = Files.createTempFile("allure-playwright-trace-", ".zip");
context.tracing().stop(new Tracing.StopOptions().setPath(trace));
if (isSourcesEmbedded) {
TraceStackSourceTrimmer.trim(trace);
}
if (attach) {
AllurePlaywright.attachTrace(name, trace);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
/*
* Copyright 2016-2026 Qameta Software Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.qameta.allure.playwright;

import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.Objects;
import java.util.regex.Pattern;
import java.util.zip.CRC32;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
import java.util.zip.ZipInputStream;
import java.util.zip.ZipOutputStream;

/**
* Trims the AspectJ-weaving frames that {@code AllurePlaywrightAspect} inserts in front of every
* advised Playwright call before a trace's {@code trace.stacks} entry is used by Trace Viewer.
*/
final class TraceStackSourceTrimmer {

private static final Logger LOGGER = LoggerFactory.getLogger(TraceStackSourceTrimmer.class);

private static final String STACKS_ENTRY = "trace.stacks";

// Playwright puts screenshots/snapshots/embedded-source blobs here - skip them completely.
private static final String RESOURCES_PREFIX = "resources/";

private static final Pattern SYNTHETIC_FRAME = Pattern.compile(
"^org\\.aspectj\\.runtime\\.reflect\\.JoinPointImpl\\."
+ "|^io\\.qameta\\.allure\\.playwright\\.AllurePlaywrightAspect\\."
+ "|_aroundBody\\d+"
+ "|\\$AjcClosure\\d+"
);

private TraceStackSourceTrimmer() {
}

/**
* Rewrites {@code trace.stacks} inside the given trace archive in place, if present.
*
* <p>If the archive has no {@code trace.stacks} entry at all — which is what actually happens when
* {@code PLAYWRIGHT_JAVA_SRC} was never configured for this session, since Playwright's Java client
* doesn't collect a stack per call at all in that case — there's nothing to trim. Checking for the
* entry up front (an O(1) central-directory lookup) skips the full unzip/rewrite of a potentially large
* archive (screenshots, snapshots, network capture) in that, likely common, case.</p>
*
* @param trace path to a Playwright trace zip, as produced by {@code Tracing.stop()}.
*/
static void trim(final Path trace) {
Path rewritten = null;
try {
if (!hasStacksEntry(trace)) {
return;
}
rewritten = Files.createTempFile("allure-playwright-trace-trimmed-", ".zip");
if (rewrite(trace, rewritten)) {
Files.move(rewritten, trace, StandardCopyOption.REPLACE_EXISTING);
rewritten = null;
}
} catch (IOException | RuntimeException e) {
LOGGER.warn("Could not trim Playwright trace stacks, attaching the trace unchanged", e);
} finally {
deleteIfExists(rewritten);
}
}

private static boolean hasStacksEntry(final Path trace) throws IOException {
try (ZipFile zip = new ZipFile(trace.toFile())) {
return zip.getEntry(STACKS_ENTRY) != null;
}
}

private static boolean rewrite(final Path trace, final Path rewritten) throws IOException {
boolean stacksFound = false;
try (ZipInputStream zis = new ZipInputStream(Files.newInputStream(trace));
ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(rewritten))) {
ZipEntry entry;
while ((entry = zis.getNextEntry()) != null) {
final String name = entry.getName();
final byte[] content = readAll(zis);
if (STACKS_ENTRY.equals(name)) {
stacksFound = true;
writeEntry(zos, name, trimStacksJson(content), false);
} else {
writeEntry(zos, name, content, name.startsWith(RESOURCES_PREFIX));
}
}
}
return stacksFound;
}

private static void writeEntry(final ZipOutputStream zos, final String name, final byte[] content,
final boolean stored)
throws IOException {
final ZipEntry outEntry = new ZipEntry(name);
if (stored) {
final CRC32 crc = new CRC32();
crc.update(content);
outEntry.setMethod(ZipEntry.STORED);
outEntry.setSize(content.length);
outEntry.setCompressedSize(content.length);
outEntry.setCrc(crc.getValue());
}
zos.putNextEntry(outEntry);
zos.write(content);
zos.closeEntry();
}

/**
* Pure JSON transform: trims each recorded stack down to its first real-looking frame.
*
* @param stacksJson the raw {@code trace.stacks} entry content, shaped as
* {@code {"files": [...], "stacks": [[callId, [[fileIndex, line, column, name], ...]], ...]}}.
* @return the rewritten content, same shape, synthetic leading frames removed from each stack.
*/
static byte[] trimStacksJson(final byte[] stacksJson) {
final JsonObject root = JsonParser.parseString(new String(stacksJson, StandardCharsets.UTF_8))
.getAsJsonObject();
final JsonArray files = root.getAsJsonArray("files");
final JsonArray stacks = root.getAsJsonArray("stacks");
for (JsonElement stackElement : stacks) {
final JsonArray stackEntry = stackElement.getAsJsonArray();
final JsonArray frames = stackEntry.get(1).getAsJsonArray();
final int cut = firstUsableFrameIndex(files, frames);
if (cut > 0) {
stackEntry.set(1, dropLeadingFrames(frames, cut));
}
}
return root.toString().getBytes(StandardCharsets.UTF_8);
}

/**
* Finds where a stack should be cut, combining two signals.
*
* <p><b>Primary:</b> the first frame anywhere in the stack that's file-resolvable. This is authoritative
* wherever it fires — once {@code PLAYWRIGHT_JAVA_SRC} is configured, only frames under the consumer's own
* source root ever resolve, so the shallowest resolved frame is the real caller, no matter how many
* library-internal frames sit between it and frame 0.</p>
*
* <p><b>Backstop:</b> if no frame anywhere resolves a file (e.g. {@code sources(true)} was set without
* ever configuring {@code PLAYWRIGHT_JAVA_SRC}), fall back to trimming the leading run of frames
* recognizable as synthetic AspectJ/Playwright-weaving glue. If that run reaches the end of the stack with
* nothing left after it, leave the stack untouched instead of trimming it down to nothing.</p>
*/
private static int firstUsableFrameIndex(final JsonArray files, final JsonArray frames) {
int resolvedAt = -1;
int syntheticRun = 0;
boolean inLeadingRun = true;
for (int i = 0; i < frames.size(); i++) {
final JsonArray frame = frames.get(i).getAsJsonArray();
if (resolvedAt < 0 && isFileResolved(files, frame)) {
resolvedAt = i;
}
if (inLeadingRun) {
if (isSyntheticFrame(frame)) {
syntheticRun = i + 1;
} else {
inLeadingRun = false;
}
}
}
if (resolvedAt >= 0) {
return resolvedAt;
}
return syntheticRun < frames.size() ? syntheticRun : 0;
}

private static boolean isFileResolved(final JsonArray files, final JsonArray frame) {
final int fileIndex = frame.get(0).getAsInt();
return fileIndex >= 0
&& fileIndex < files.size()
&& !files.get(fileIndex).getAsString().isEmpty();
}

private static boolean isSyntheticFrame(final JsonArray frame) {
return SYNTHETIC_FRAME.matcher(frame.get(3).getAsString()).find();
}

private static JsonArray dropLeadingFrames(final JsonArray frames, final int cut) {
final JsonArray trimmed = new JsonArray();
for (int i = cut; i < frames.size(); i++) {
trimmed.add(frames.get(i));
}
return trimmed;
}

private static byte[] readAll(final InputStream in) throws IOException {
final ByteArrayOutputStream buffer = new ByteArrayOutputStream();
final byte[] chunk = new byte[8192];
int read;
while ((read = in.read(chunk)) != -1) {
buffer.write(chunk, 0, read);
}
return buffer.toByteArray();
}

private static void deleteIfExists(final Path file) {
if (Objects.isNull(file)) {
return;
}
try {
Files.deleteIfExists(file);
} catch (IOException e) {
LOGGER.warn("Could not delete temporary file {}", file, e);
}
}
}
1 change: 1 addition & 0 deletions allure-playwright/src/main/java/module-info.java
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
requires transitive io.qameta.allure.commons;
requires playwright;
requires static org.aspectj.runtime;
requires static com.google.gson;
requires org.slf4j;

exports io.qameta.allure.playwright;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/*
* Copyright 2016-2026 Qameta Software Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.qameta.allure.playwright;

import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;

/**
* Checks {@link AllurePlaywright#shouldEmbedSources()} for the case that actually matters: relies on this
* test environment not having {@code PLAYWRIGHT_JAVA_SRC} set (there's no supported way to fake that from
* inside the JVM), and flips {@code allure.playwright.trace.sources} on via a system property, which
* {@code PropertiesUtils} reads on top of {@code allure.properties}.
*/
class AllurePlaywrightEmbedSourcesTest {

private static final String TRACE_SOURCES = "allure.playwright.trace.sources";

@AfterEach
void clearProperty() {
System.clearProperty(TRACE_SOURCES);
}

@Test
void shouldNotEmbedWhenJavaSrcEnvVarIsNotSet() {
System.setProperty(TRACE_SOURCES, "true");

assertThat(AllurePlaywright.shouldEmbedSources()).isFalse();
}
}
Loading
Loading