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
2 changes: 2 additions & 0 deletions allure-playwright/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@ dependencies {
agent("org.aspectj:aspectjweaver")
api(project(":allure-java-commons"))
compileOnly("com.microsoft.playwright:playwright:$playwrightVersion")
compileOnly("com.google.code.gson:gson")
compileOnly("org.aspectj:aspectjrt")
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 @@ -239,11 +239,13 @@ public static TraceSession startTracing(final String name, final BrowserContext
if (context == null) {
throw new IllegalArgumentException("context must not be null");
}
final boolean embedSources = AllurePlaywrightConfig.shouldEmbedTraceSources();
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;
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,152 @@
/*
* 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.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.FileSystem;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.regex.Pattern;

/**
* 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";

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>Only {@code trace.stacks} is read into memory and replaced; all other entries (screenshots, snapshots,
* network bodies, sources) are copied as-is, keeping their original compressed payloads. If the archive has
* no {@code trace.stacks} entry (Playwright doesn't collect stacks without {@code PLAYWRIGHT_JAVA_SRC}), the
* file is left untouched. Fails open: on any error the trace is left unchanged.</p>
*
* @param trace path to a Playwright trace zip, as produced by {@code Tracing.stop()}.
*/
static void trim(final Path trace) {
try (FileSystem zip = FileSystems.newFileSystem(trace)) {
final Path stacks = zip.getPath(STACKS_ENTRY);
if (Files.notExists(stacks)) {
return;
}
Files.write(stacks, trimStacksJson(Files.readAllBytes(stacks)));
} catch (IOException | RuntimeException e) {
LOGGER.warn("Could not trim Playwright trace stacks, attaching the trace unchanged", e);
}
}

/**
* 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;
}
}
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 com.microsoft.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,152 @@
/*
* 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 com.microsoft.playwright.Browser;
import com.microsoft.playwright.BrowserContext;
import com.microsoft.playwright.BrowserType;
import com.microsoft.playwright.Page;
import com.microsoft.playwright.Playwright;
import com.microsoft.playwright.PlaywrightException;
import io.qameta.allure.AllureExternalKey;
import io.qameta.allure.model.TestResult;
import io.qameta.allure.test.AllureResults;
import io.qameta.allure.test.IsolatedLifecycle;
import io.qameta.allure.test.RunUtils;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.api.parallel.ResourceLock;

import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.UncheckedIOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Collections;
import java.util.UUID;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;

import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
import static org.junit.jupiter.api.parallel.ResourceAccessMode.READ_WRITE;

/**
* Checks source embedding through the public {@link AllurePlaywright#startTracing(String, BrowserContext)} helper
* when {@code PLAYWRIGHT_JAVA_SRC} is configured per Playwright instance via {@code CreateOptions.setEnv()} rather
* than in the JVM process environment.
*/
@ResourceLock(
value = "io.qameta.allure.playwright.lifecycle",
mode = READ_WRITE
)
@IsolatedLifecycle
class AllurePlaywrightEmbedSourcesTest {

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

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

@Test
void shouldEmbedSourcesWithPerInstanceJavaSrcConfiguration(@TempDir final Path tempDir) throws IOException {
System.setProperty(TRACE_SOURCES, "true");
final Path testSrcRoot = Path.of("src", "test", "java").toAbsolutePath();

final AllureResults results;
try (Playwright playwright = Playwright.create(
new Playwright.CreateOptions()
.setEnv(Collections.singletonMap("PLAYWRIGHT_JAVA_SRC", testSrcRoot.toString()))
)) {
final Browser browser;
try {
browser = playwright.chromium().launch(
new BrowserType.LaunchOptions()
.setHeadless(true)
.setArgs(Collections.singletonList("--no-sandbox"))
);
} catch (PlaywrightException e) {
assumeTrue(false, "Chromium is not available for Playwright integration tests: " + e.getMessage());
return;
}
final BrowserContext context = browser.newContext();
final Page page = context.newPage();
results = RunUtils.runTests(lifecycle -> {
final AllureExternalKey testKey = AllureExternalKey.random(AllurePlaywrightEmbedSourcesTest.class);
lifecycle.scheduleTest(testKey, new TestResult().setUuid(UUID.randomUUID().toString()));
lifecycle.startTest(testKey);
try (TraceSession traceSession = AllurePlaywright.startTracing("Trace", context)) {
page.setContent("<button>Trace</button>");
page.click("button");
} finally {
lifecycle.stopTest(testKey);
lifecycle.writeTest(testKey);
}
});
context.close();
browser.close();
}

assertThat(results.getAttachments()).hasSize(1);
final Path trace = tempDir.resolve("trace.zip");
Files.write(trace, results.getAttachments().values().iterator().next());

try (ZipFile zip = new ZipFile(trace.toFile())) {
final byte[] testSource = Files.readAllBytes(
testSrcRoot.resolve("io/qameta/allure/playwright/AllurePlaywrightEmbedSourcesTest.java")
);
assertThat(zip.stream().map(entry -> readEntry(zip, entry)))
.as("embedded source of this test class")
.anySatisfy(content -> assertThat(content).isEqualTo(testSource));
final ZipEntry stacksEntry = zip.getEntry("trace.stacks");
assertThat(stacksEntry).as("trace.stacks entry").isNotNull();
final JsonObject stacksJson;
try (InputStream in = zip.getInputStream(stacksEntry)) {
stacksJson = JsonParser.parseReader(new InputStreamReader(in, StandardCharsets.UTF_8))
.getAsJsonObject();
}
final JsonArray files = stacksJson.getAsJsonArray("files");
final JsonArray stacks = stacksJson.getAsJsonArray("stacks");
assertThat(stacks).isNotEmpty();
for (JsonElement stackElement : stacks) {
final JsonArray frame0 = stackElement.getAsJsonArray().get(1).getAsJsonArray()
.get(0).getAsJsonArray();
assertThat(frame0.get(3).getAsString())
.doesNotContain("JoinPointImpl", "AllurePlaywrightAspect", "AjcClosure");
assertThat(files.get(frame0.get(0).getAsInt()).getAsString())
.endsWith("AllurePlaywrightEmbedSourcesTest.java");
}
}
}

private static byte[] readEntry(final ZipFile zip, final ZipEntry entry) {
try (InputStream in = zip.getInputStream(entry)) {
return in.readAllBytes();
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
}
Loading
Loading