From 3d8022062c5c3087849b0b8cfc805f3a5a37c246 Mon Sep 17 00:00:00 2001 From: Anton Filichkin Date: Fri, 18 Sep 2026 19:50:06 +0300 Subject: [PATCH] feat(playwright): make trace source embedding configurable and trim AspectJ frames from trace stacks Playwright's Tracing.StartOptions#setSources embeds a stack per action, but frame 0 is always synthetic AspectJ-weaving glue inserted by AllurePlaywrightAspect, not the real caller - making Trace Viewer's Source tab useless without this. - Add allure.playwright.trace.sources (default false) to opt into embedding sources in the trace. - Add TraceStackSourceTrimmer, which rewrites the trace.stacks entry in place: it resolves the first file-backed frame in each stack, falling back to stripping the leading run of recognizable AspectJ/Playwright weaving frames when no frame resolves. Fails open on any error, leaving the trace attached unchanged. - Declare the module's new compile-time-only dependency on Gson in module-info.java. --- allure-playwright/build.gradle.kts | 5 + .../allure/playwright/AllurePlaywright.java | 22 +- .../playwright/AllurePlaywrightConfig.java | 5 + .../playwright/DefaultTraceSession.java | 7 +- .../playwright/TraceStackSourceTrimmer.java | 232 +++++++++++++ .../src/main/java/module-info.java | 1 + .../AllurePlaywrightEmbedSourcesTest.java | 44 +++ .../TraceStackSourceTrimmerTest.java | 322 ++++++++++++++++++ 8 files changed, 635 insertions(+), 3 deletions(-) create mode 100644 allure-playwright/src/main/java/io/qameta/allure/playwright/TraceStackSourceTrimmer.java create mode 100644 allure-playwright/src/test/java/io/qameta/allure/playwright/AllurePlaywrightEmbedSourcesTest.java create mode 100644 allure-playwright/src/test/java/io/qameta/allure/playwright/TraceStackSourceTrimmerTest.java diff --git a/allure-playwright/build.gradle.kts b/allure-playwright/build.gradle.kts index c2be27ea6..095777885 100644 --- a/allure-playwright/build.gradle.kts +++ b/allure-playwright/build.gradle.kts @@ -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") diff --git a/allure-playwright/src/main/java/io/qameta/allure/playwright/AllurePlaywright.java b/allure-playwright/src/main/java/io/qameta/allure/playwright/AllurePlaywright.java index 843c6776e..a045e42f0 100644 --- a/allure-playwright/src/main/java/io/qameta/allure/playwright/AllurePlaywright.java +++ b/allure-playwright/src/main/java/io/qameta/allure/playwright/AllurePlaywright.java @@ -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; /** @@ -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 SUPPRESS_ASPECT = new ThreadLocal() { @Override @@ -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) { diff --git a/allure-playwright/src/main/java/io/qameta/allure/playwright/AllurePlaywrightConfig.java b/allure-playwright/src/main/java/io/qameta/allure/playwright/AllurePlaywrightConfig.java index 8bdf50655..2abd2b1e2 100644 --- a/allure-playwright/src/main/java/io/qameta/allure/playwright/AllurePlaywrightConfig.java +++ b/allure-playwright/src/main/java/io/qameta/allure/playwright/AllurePlaywrightConfig.java @@ -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"; @@ -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))); } diff --git a/allure-playwright/src/main/java/io/qameta/allure/playwright/DefaultTraceSession.java b/allure-playwright/src/main/java/io/qameta/allure/playwright/DefaultTraceSession.java index 100545816..1c08264cb 100644 --- a/allure-playwright/src/main/java/io/qameta/allure/playwright/DefaultTraceSession.java +++ b/allure-playwright/src/main/java/io/qameta/allure/playwright/DefaultTraceSession.java @@ -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 @@ -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); } diff --git a/allure-playwright/src/main/java/io/qameta/allure/playwright/TraceStackSourceTrimmer.java b/allure-playwright/src/main/java/io/qameta/allure/playwright/TraceStackSourceTrimmer.java new file mode 100644 index 000000000..44a33c36f --- /dev/null +++ b/allure-playwright/src/main/java/io/qameta/allure/playwright/TraceStackSourceTrimmer.java @@ -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. + * + *

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.

+ * + * @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. + * + *

Primary: 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.

+ * + *

Backstop: 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.

+ */ + 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); + } + } +} diff --git a/allure-playwright/src/main/java/module-info.java b/allure-playwright/src/main/java/module-info.java index 01202eb4d..1770aefc5 100644 --- a/allure-playwright/src/main/java/module-info.java +++ b/allure-playwright/src/main/java/module-info.java @@ -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; diff --git a/allure-playwright/src/test/java/io/qameta/allure/playwright/AllurePlaywrightEmbedSourcesTest.java b/allure-playwright/src/test/java/io/qameta/allure/playwright/AllurePlaywrightEmbedSourcesTest.java new file mode 100644 index 000000000..a03594f79 --- /dev/null +++ b/allure-playwright/src/test/java/io/qameta/allure/playwright/AllurePlaywrightEmbedSourcesTest.java @@ -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(); + } +} diff --git a/allure-playwright/src/test/java/io/qameta/allure/playwright/TraceStackSourceTrimmerTest.java b/allure-playwright/src/test/java/io/qameta/allure/playwright/TraceStackSourceTrimmerTest.java new file mode 100644 index 000000000..3f529a3f8 --- /dev/null +++ b/allure-playwright/src/test/java/io/qameta/allure/playwright/TraceStackSourceTrimmerTest.java @@ -0,0 +1,322 @@ +/* + * 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 com.microsoft.playwright.Tracing; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collections; +import java.util.Random; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; +import java.util.zip.ZipOutputStream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +/** + * Checks {@link TraceStackSourceTrimmer}'s pure {@code trace.stacks} transform directly against fixture payloads, + * plus one end-to-end capture against a real trace to confirm the fix holds against the actual format Playwright + * writes, not just an assumed one. + */ +class TraceStackSourceTrimmerTest { + + /** + * Checks that a run of synthetic AspectJ/Playwright-weaving frames in front of a real caller is trimmed. + */ + @Test + void shouldTrimLeadingSyntheticFrames() { + final byte[] input = ("{" + + "\"files\":[\"\",\"/src/Caller.java\"]," + + "\"stacks\":[[1,[" + + "[0,164,0,\"org.aspectj.runtime.reflect.JoinPointImpl.proceed\"]," + + "[0,119,0,\"io.qameta.allure.playwright.AllurePlaywrightAspect.logPlaywrightStep\"]," + + "[0,723,0,\"com.microsoft.playwright.impl.PageImpl.click\"]," + + "[0,6,0,\"com.microsoft.playwright.Page.click_aroundBody6\"]," + + "[0,1,0,\"com.microsoft.playwright.Page$AjcClosure7.run\"]," + + "[1,34,0,\"com.example.Caller.run\"]," + + "[0,565,0,\"java.lang.reflect.Method.invoke\"]" + + "]]]}").getBytes(StandardCharsets.UTF_8); + + final JsonArray frames = trimAndGetFrames(input, 1); + + assertThat(frames).hasSize(2); + assertThat(frames.get(0).getAsJsonArray().get(3).getAsString()).isEqualTo("com.example.Caller.run"); + assertThat(frames.get(1).getAsJsonArray().get(3).getAsString()).isEqualTo("java.lang.reflect.Method.invoke"); + } + + /** + * Checks that a stack whose first frame is already file-resolvable is left untouched, even when later frames + * would otherwise match the synthetic-frame denylist. + */ + @Test + void shouldLeaveAlreadyResolvedStackUnchanged() { + final byte[] input = ("{" + + "\"files\":[\"/src/Caller.java\"]," + + "\"stacks\":[[1,[" + + "[0,10,0,\"com.example.Caller.run\"]," + + "[-1,164,0,\"org.aspectj.runtime.reflect.JoinPointImpl.proceed\"]" + + "]]]}").getBytes(StandardCharsets.UTF_8); + + final JsonArray frames = trimAndGetFrames(input, 1); + + assertThat(frames).hasSize(2); + assertThat(frames.get(0).getAsJsonArray().get(3).getAsString()).isEqualTo("com.example.Caller.run"); + } + + /** + * Checks that a stack with no file-resolvable frame and no non-synthetic frame is left untouched rather than + * trimmed down to nothing — a safe no-op, matching what happens when a consumer enables {@code sources} + * without ever configuring {@code PLAYWRIGHT_JAVA_SRC}. + */ + @Test + void shouldNotEmptyAFullyUnresolvedStack() { + final byte[] input = ("{" + + "\"files\":[\"\"]," + + "\"stacks\":[[1,[" + + "[0,164,0,\"org.aspectj.runtime.reflect.JoinPointImpl.proceed\"]," + + "[0,119,0,\"io.qameta.allure.playwright.AllurePlaywrightAspect.logPlaywrightStep\"]" + + "]]]}").getBytes(StandardCharsets.UTF_8); + + final JsonArray frames = trimAndGetFrames(input, 1); + + assertThat(frames).hasSize(2); + } + + /** + * Checks that a stack with no synthetic frames at all (an unadvised call) is left completely unchanged. + */ + @Test + void shouldLeaveNonSyntheticStackUnchanged() { + final byte[] input = ("{" + + "\"files\":[\"\"]," + + "\"stacks\":[[1,[" + + "[0,10,0,\"com.example.Caller.run\"]," + + "[0,20,0,\"com.example.Other.helper\"]" + + "]]]}").getBytes(StandardCharsets.UTF_8); + + final JsonArray frames = trimAndGetFrames(input, 1); + + assertThat(frames).hasSize(2); + assertThat(frames.get(0).getAsJsonArray().get(3).getAsString()).isEqualTo("com.example.Caller.run"); + } + + /** + * Checks that multiple stacks in the same payload are trimmed independently of each other. + */ + @Test + void shouldTrimEachStackIndependently() { + final byte[] input = ("{" + + "\"files\":[\"\",\"/src/Caller.java\"]," + + "\"stacks\":[" + + "[1,[[0,164,0,\"org.aspectj.runtime.reflect.JoinPointImpl.proceed\"],[1,1,0,\"com.example.A.a\"]]]," + + "[2,[[1,2,0,\"com.example.B.b\"]]]" + + "]}").getBytes(StandardCharsets.UTF_8); + + final JsonObject root = JsonParser.parseString( + new String(TraceStackSourceTrimmer.trimStacksJson(input), StandardCharsets.UTF_8) + ).getAsJsonObject(); + final JsonArray stacks = root.getAsJsonArray("stacks"); + + assertThat(stacks.get(0).getAsJsonArray().get(1).getAsJsonArray()).hasSize(1); + assertThat(stacks.get(1).getAsJsonArray().get(1).getAsJsonArray()).hasSize(1); + } + + /** + * Checks that {@link TraceStackSourceTrimmer#trim(Path)} fails open: a file that isn't a valid zip at all is + * left byte-for-byte untouched instead of being partially rewritten or deleted. + * + * @param tempDir a directory managed by JUnit for this test's temporary file. + */ + @Test + void shouldLeaveFileUntouchedWhenNotAValidZip(@TempDir final Path tempDir) throws IOException { + final Path notAZip = tempDir.resolve("not-a-trace.zip"); + final byte[] original = "not actually a zip file".getBytes(StandardCharsets.UTF_8); + Files.write(notAZip, original); + + TraceStackSourceTrimmer.trim(notAZip); + + assertThat(Files.readAllBytes(notAZip)).isEqualTo(original); + } + + /** + * Checks that a valid trace with no {@code trace.stacks} entry at all — what actually happens when + * {@code PLAYWRIGHT_JAVA_SRC} was never configured, since Playwright's client then never collects a stack + * per call — is left completely, byte-for-byte untouched, without paying for a full unzip/rewrite. + * + *

Byte-for-byte, not just functionally equivalent, is the point of this test: a full rewrite through + * {@code ZipOutputStream} would re-derive entry metadata and almost certainly not reproduce the original + * bytes exactly, so this also guards against the short-circuit in + * {@link TraceStackSourceTrimmer#trim(Path)} silently regressing back into always rewriting.

+ * + * @param tempDir a directory managed by JUnit for this test's temporary file. + */ + @Test + void shouldLeaveTraceUntouchedWhenNoStacksEntry(@TempDir final Path tempDir) throws IOException { + final Path trace = tempDir.resolve("trace.zip"); + writeZip(trace, "trace.trace", "{\"type\":\"context-options\"}".getBytes(StandardCharsets.UTF_8)); + final byte[] original = Files.readAllBytes(trace); + + TraceStackSourceTrimmer.trim(trace); + + assertThat(Files.readAllBytes(trace)).isEqualTo(original); + } + + /** + * Checks that {@code resources/} entries (screenshots, snapshots — already-compressed-or-incompressible + * binary blobs) are stored rather than re-compressed when a trace does get rewritten: content must round + * trip exactly, and the entry should come out {@link ZipEntry#STORED} rather than + * {@link ZipEntry#DEFLATED}, since re-deflating such content buys nothing but CPU time. + * + * @param tempDir a directory managed by JUnit for this test's temporary file. + */ + @Test + void shouldStoreResourceEntriesRatherThanRecompressThem(@TempDir final Path tempDir) throws IOException { + final Path trace = tempDir.resolve("trace.zip"); + final byte[] screenshot = new byte[4096]; + new Random(42).nextBytes(screenshot); + try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(trace))) { + zos.putNextEntry(new ZipEntry("resources/page@abc.jpeg")); + zos.write(screenshot); + zos.closeEntry(); + zos.putNextEntry(new ZipEntry("trace.stacks")); + zos.write( + ("{\"files\":[\"\"],\"stacks\":[[1,[[0,1,0,\"com.example.Caller.run\"]]]]}") + .getBytes(StandardCharsets.UTF_8) + ); + zos.closeEntry(); + } + + TraceStackSourceTrimmer.trim(trace); + + try (ZipFile zip = new ZipFile(trace.toFile())) { + ZipEntry resourceEntry = zip.getEntry("resources/page@abc.jpeg"); + assertThat(resourceEntry.getMethod()).isEqualTo(ZipEntry.STORED); + try (InputStream in = zip.getInputStream(resourceEntry)) { + assertThat(in.readAllBytes()).isEqualTo(screenshot); + } + } + } + + private static void writeZip(final Path zip, final String entryName, final byte[] content) throws IOException { + try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(zip))) { + zos.putNextEntry(new ZipEntry(entryName)); + zos.write(content); + zos.closeEntry(); + } + } + + /** + * End-to-end: captures a real trace from an aspect-advised {@code page.click()} call, with + * {@code PLAYWRIGHT_JAVA_SRC} pointed at this test's own source root, trims it, and confirms frame 0 of + * the click action's stack now resolves to this test class rather than the synthetic AspectJ frame. + * + *

Uses its own {@link Playwright} instance (rather than a shared one) because {@code PLAYWRIGHT_JAVA_SRC} + * is only read once, at driver-connection time.

+ */ + @Test + void shouldResolveRealCallerAfterTrimmingARealTrace(@TempDir final Path tempDir) throws IOException { + final Path testSrcRoot = Path.of("src", "test", "java").toAbsolutePath(); + + 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(); + context.tracing().start( + new Tracing.StartOptions().setScreenshots(true).setSnapshots(true) + .setSources(true) + ); + final Page page = context.newPage(); + page.setContent(""); + page.click("button"); + + final Path trace = tempDir.resolve("trace.zip"); + context.tracing().stop(new Tracing.StopOptions().setPath(trace)); + context.close(); + browser.close(); + + TraceStackSourceTrimmer.trim(trace); + + final JsonObject stacksJson = readStacksEntry(trace); + 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(); + final int fileIndex = frame0.get(0).getAsInt(); + final String name = frame0.get(3).getAsString(); + assertThat(name).doesNotContain("JoinPointImpl", "AllurePlaywrightAspect", "AjcClosure"); + if (fileIndex >= 0) { + assertThat(files.get(fileIndex).getAsString()).isNotEmpty(); + } + } + } + } + + private static JsonArray trimAndGetFrames(final byte[] input, final int stackIndex) { + final byte[] output = TraceStackSourceTrimmer.trimStacksJson(input); + final JsonObject root = JsonParser.parseString(new String(output, StandardCharsets.UTF_8)).getAsJsonObject(); + for (JsonElement stackElement : root.getAsJsonArray("stacks")) { + final JsonArray stackEntry = stackElement.getAsJsonArray(); + if (stackEntry.get(0).getAsInt() == stackIndex) { + return stackEntry.get(1).getAsJsonArray(); + } + } + throw new AssertionError("No stack with id " + stackIndex); + } + + private static JsonObject readStacksEntry(final Path trace) throws IOException { + try (ZipFile zip = new ZipFile(trace.toFile())) { + final ZipEntry entry = zip.getEntry("trace.stacks"); + assertThat(entry).as("trace.stacks entry").isNotNull(); + try (InputStream in = zip.getInputStream(entry)) { + return JsonParser.parseReader(new InputStreamReader(in, StandardCharsets.UTF_8)) + .getAsJsonObject(); + } + } + } +}