From 80e09bed2178875808a8bf9effc3969bca1949c6 Mon Sep 17 00:00:00 2001 From: Chirag Bitoni Date: Sat, 7 Nov 2026 19:03:09 +0530 Subject: [PATCH] fix(selenide): deduplicate attachments on nested step failures (#1453) When Selenide logs nested steps, failure in an inner step triggers afterEvent with FAIL for both the inner step and enclosing steps. Previously, this caused duplicate screenshots and page source attachments across all nesting levels. Track captured failure throwables using a WeakHashMap-backed set and verify the exception and its cause chain before capturing attachments, preventing duplicate attachments for the same failure while preserving attachments for distinct errors. --- .../allure/selenide/AllureSelenide.java | 107 +++++++++++------- .../allure/selenide/AllureSelenideTest.java | 97 ++++++++++++++++ 2 files changed, 163 insertions(+), 41 deletions(-) diff --git a/allure-selenide/src/main/java/io/qameta/allure/selenide/AllureSelenide.java b/allure-selenide/src/main/java/io/qameta/allure/selenide/AllureSelenide.java index ef45f1a78..bfa5efd07 100644 --- a/allure-selenide/src/main/java/io/qameta/allure/selenide/AllureSelenide.java +++ b/allure-selenide/src/main/java/io/qameta/allure/selenide/AllureSelenide.java @@ -32,9 +32,14 @@ import org.slf4j.LoggerFactory; import java.io.ByteArrayInputStream; +import java.util.Collections; import java.util.EnumMap; +import java.util.IdentityHashMap; import java.util.Map; +import java.util.Objects; import java.util.Optional; +import java.util.Set; +import java.util.WeakHashMap; import java.util.logging.Level; import static io.qameta.allure.util.ResultsUtils.getStatus; @@ -55,6 +60,7 @@ public class AllureSelenide implements LogEventListener { private boolean savePageHtml = true; private boolean includeSelenideLocatorsSteps = true; private final Map logTypesToSave = new EnumMap<>(LogType.class); + private final Set capturedErrors = Collections.newSetFromMap(new WeakHashMap<>()); private final AllureLifecycle lifecycle; /** @@ -167,52 +173,71 @@ public void beforeEvent(final LogEvent event) { } } + private boolean shouldCaptureAttachments(final Throwable error) { + if (Objects.isNull(error)) { + return true; + } + final Set visited = Collections.newSetFromMap(new IdentityHashMap<>()); + for (Throwable current = error; current != null && visited.add(current); current = current.getCause()) { + if (capturedErrors.contains(current)) { + return false; + } + } + capturedErrors.add(error); + return true; + } + + private void captureAttachmentsOnFailure(final LogEvent event) { + if (!event.getStatus().equals(LogEvent.EventStatus.FAIL) || !shouldCaptureAttachments(event.getError())) { + return; + } + lifecycle.getCurrentExecutableKey().ifPresent(owner -> { + if (saveScreenshots) { + getScreenshotBytes() + .ifPresent( + bytes -> lifecycle.addAttachment( + owner, + "Screenshot", + "image/png", + new ByteArrayInputStream(bytes), + AttachmentOptions.empty() + ) + ); + } + if (savePageHtml) { + getPageSourceBytes() + .ifPresent( + bytes -> lifecycle.addAttachment( + owner, + "Page source", + "text/html", + new ByteArrayInputStream(bytes), + AttachmentOptions.empty() + ) + ); + } + if (!logTypesToSave.isEmpty()) { + logTypesToSave + .forEach((logType, level) -> { + final byte[] content = getBrowserLogs(logType, level).getBytes(UTF_8); + lifecycle.addAttachment( + owner, + "Logs from: " + logType, + "application/json", + new ByteArrayInputStream(content), + AttachmentOptions.withFileExtension(".txt") + ); + }); + } + }); + } + /** * {@inheritDoc} */ @Override public void afterEvent(final LogEvent event) { - if (event.getStatus().equals(LogEvent.EventStatus.FAIL)) { - lifecycle.getCurrentExecutableKey().ifPresent(owner -> { - if (saveScreenshots) { - getScreenshotBytes() - .ifPresent( - bytes -> lifecycle.addAttachment( - owner, - "Screenshot", - "image/png", - new ByteArrayInputStream(bytes), - AttachmentOptions.empty() - ) - ); - } - if (savePageHtml) { - getPageSourceBytes() - .ifPresent( - bytes -> lifecycle.addAttachment( - owner, - "Page source", - "text/html", - new ByteArrayInputStream(bytes), - AttachmentOptions.empty() - ) - ); - } - if (!logTypesToSave.isEmpty()) { - logTypesToSave - .forEach((logType, level) -> { - final byte[] content = getBrowserLogs(logType, level).getBytes(UTF_8); - lifecycle.addAttachment( - owner, - "Logs from: " + logType, - "application/json", - new ByteArrayInputStream(content), - AttachmentOptions.withFileExtension(".txt") - ); - }); - } - }); - } + captureAttachmentsOnFailure(event); if (stepsShouldBeLogged(event)) { lifecycle.getCurrentExecutableKey().ifPresent(owner -> { diff --git a/allure-selenide/src/test/java/io/qameta/allure/selenide/AllureSelenideTest.java b/allure-selenide/src/test/java/io/qameta/allure/selenide/AllureSelenideTest.java index 69cfbf82c..3993ef80b 100644 --- a/allure-selenide/src/test/java/io/qameta/allure/selenide/AllureSelenideTest.java +++ b/allure-selenide/src/test/java/io/qameta/allure/selenide/AllureSelenideTest.java @@ -396,6 +396,103 @@ void shouldSupportNestedSteps() { .containsExactly("child1", "child2", "child3"); } + @AllureFeatures.Attachments + @Test + void shouldDeduplicateAttachmentsOnNestedStepFailures() { + final WebDriver wdMock = mock(WebDriver.class, withSettings().extraInterfaces(TakesScreenshot.class)); + WebDriverRunner.setWebDriver(wdMock); + doReturn("screenshot-data".getBytes(StandardCharsets.UTF_8)) + .when((TakesScreenshot) wdMock).getScreenshotAs(OutputType.BYTES); + + final Throwable failure = new AssertionError("element not found"); + + final AllureResults results = runSelenideTestContext(() -> { + final AllureSelenide selenide = new AllureSelenide() + .savePageSource(false) + .screenshots(true); + SelenideLogger.addListener(UUID.randomUUID().toString(), selenide); + + final SelenideLog outerLog = SelenideLogger.beginStep("outer", "outerMethod()"); + final SelenideLog innerLog = SelenideLogger.beginStep("inner", "innerMethod()"); + + SelenideLogger.commitStep(innerLog, failure); + SelenideLogger.commitStep(outerLog, failure); + }); + + final List steps = extractAllStepsFromResults(results); + assertThat(steps).hasSize(1); + final StepResult outerStep = steps.get(0); + assertThat(outerStep.getSteps()).hasSize(1); + final StepResult innerStep = outerStep.getSteps().get(0); + + assertThat(innerStep.getAttachments()).hasSize(1); + assertThat(outerStep.getAttachments()).isEmpty(); + assertThat(results.getAttachments()).hasSize(1); + } + + @AllureFeatures.Attachments + @Test + void shouldDeduplicateAttachmentsWhenExceptionIsWrappedInCause() { + final WebDriver wdMock = mock(WebDriver.class, withSettings().extraInterfaces(TakesScreenshot.class)); + WebDriverRunner.setWebDriver(wdMock); + doReturn("screenshot-data".getBytes(StandardCharsets.UTF_8)) + .when((TakesScreenshot) wdMock).getScreenshotAs(OutputType.BYTES); + + final Throwable rootCause = new AssertionError("element not found"); + final Throwable wrappedError = new RuntimeException("step failed", rootCause); + + final AllureResults results = runSelenideTestContext(() -> { + final AllureSelenide selenide = new AllureSelenide() + .savePageSource(false) + .screenshots(true); + SelenideLogger.addListener(UUID.randomUUID().toString(), selenide); + + final SelenideLog outerLog = SelenideLogger.beginStep("outer", "outerMethod()"); + final SelenideLog innerLog = SelenideLogger.beginStep("inner", "innerMethod()"); + + SelenideLogger.commitStep(innerLog, rootCause); + SelenideLogger.commitStep(outerLog, wrappedError); + }); + + final List steps = extractAllStepsFromResults(results); + assertThat(steps).hasSize(1); + final StepResult outerStep = steps.get(0); + assertThat(outerStep.getSteps()).hasSize(1); + final StepResult innerStep = outerStep.getSteps().get(0); + + assertThat(innerStep.getAttachments()).hasSize(1); + assertThat(outerStep.getAttachments()).isEmpty(); + assertThat(results.getAttachments()).hasSize(1); + } + + @AllureFeatures.Attachments + @Test + void shouldAttachSeparatelyForDistinctFailures() { + final WebDriver wdMock = mock(WebDriver.class, withSettings().extraInterfaces(TakesScreenshot.class)); + WebDriverRunner.setWebDriver(wdMock); + doReturn("screenshot-data".getBytes(StandardCharsets.UTF_8)) + .when((TakesScreenshot) wdMock).getScreenshotAs(OutputType.BYTES); + + final AllureResults results = runSelenideTestContext(() -> { + final AllureSelenide selenide = new AllureSelenide() + .savePageSource(false) + .screenshots(true); + SelenideLogger.addListener(UUID.randomUUID().toString(), selenide); + + final SelenideLog step1 = SelenideLogger.beginStep("step1", "method1()"); + SelenideLogger.commitStep(step1, new AssertionError("first failure")); + + final SelenideLog step2 = SelenideLogger.beginStep("step2", "method2()"); + SelenideLogger.commitStep(step2, new AssertionError("second failure")); + }); + + final List steps = extractAllStepsFromResults(results); + assertThat(steps).hasSize(2); + assertThat(steps.get(0).getAttachments()).hasSize(1); + assertThat(steps.get(1).getAttachments()).hasSize(1); + assertThat(results.getAttachments()).hasSize(2); + } + private static StepResult extractStepFromResults(AllureResults results) { return results .getTestResults().iterator().next()