Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
import org.testng.ITestListener;
import org.testng.ITestNGMethod;
import org.testng.ITestResult;
import org.testng.TestRunner;
import org.testng.annotations.Parameters;
import org.testng.internal.ConstructorOrMethod;
import org.testng.xml.XmlSuite;
Expand All @@ -76,6 +77,7 @@
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Consumer;
import java.util.stream.Collectors;
import java.util.stream.Stream;
Expand Down Expand Up @@ -135,6 +137,19 @@ public class AllureTestNg

private static final boolean HAS_CUCUMBERJVM7_IN_CLASSPATH = isClassAvailableOnClasspath("io.qameta.allure.cucumber7jvm.AllureCucumber7Jvm");

/**
* TestNG dispatches finish events (onTestSuccess/onTestFailure/onTestSkipped) to test listeners in reverse
* registration order since 7.5, while onTestStart keeps registration order. The markers pin that behavior:
* {@code org.testng.internal.invokers.TestInvoker} appeared in 7.5 together with the reversal, and
* {@code org.testng.ListenerComparator} is a public-API companion from 7.10 kept as a fallback in case the
* internal class moves again. On older TestNG both events run in registration order, where taking the front
* seat would invert the intent and close tests before custom listeners run.
*/
private static final boolean FINISH_EVENTS_REVERSED = isClassAvailableOnClasspath("org.testng.internal.invokers.TestInvoker")
|| isClassAvailableOnClasspath("org.testng.ListenerComparator");

private static final AtomicBoolean LISTENER_ORDER_PROBLEM_REPORTED = new AtomicBoolean();

/**
* The test currently running on this thread: its allure uuid plus the testng result it belongs to. The result acts
* as the invocation identity, so a terminal callback can tell an already-started test from one that was skipped
Expand Down Expand Up @@ -259,6 +274,8 @@ public void onStart(final ISuite suite) {

@Override
public void onStart(final ITestContext context) {
promoteSelfToFirstListener(context);

final String uuid = getUniqueUuid(context);
getLifecycle().registerScope(scopeKey(uuid));

Expand All @@ -271,6 +288,68 @@ public void onStart(final ITestContext context) {
}
}

/**
* Moves this listener to the front of the test runner's listener list, so it handles onTestStart first (the
* test is already scheduled when custom listeners run) and finish events last (the test is still running
* while custom listeners add their screenshots-on-failure and other evidence through the Allure API).
* Ordering is otherwise defined by the registration mechanism alone, and the popular ones — the
* {@code @Listeners} annotation and runner properties — register custom listeners in the losing position.
*
* <p>The move rides on {@link TestRunner#getTestListeners()} exposing the live list, which is internal
* behavior; when any step of it fails, the run continues with the default order and the problem is reported
* once, so the fallback is never worse than not reordering at all.</p>
*/
private void promoteSelfToFirstListener(final ITestContext context) {
if (!FINISH_EVENTS_REVERSED) {
// pre-7.5 TestNG runs finish events in registration order too, so there is no winning position
// and the front seat would close tests before custom listeners run
return;
}
if (!(context instanceof TestRunner)) {
reportListenerOrderProblem("the test context is not org.testng.TestRunner", null);
return;
}
final TestRunner runner = (TestRunner) context;
final List<ITestListener> listeners = runner.getTestListeners();
// a defensive copy would swallow the reorder silently; a fresh instance per call gives it away
if (listeners != runner.getTestListeners()) {
reportListenerOrderProblem("TestRunner.getTestListeners no longer exposes the live list", null);
return;
}
moveSelfToFront(listeners);
}

/**
* The list surgery of {@link #promoteSelfToFirstListener(ITestContext)}: adds this listener at the front
* before removing the old occurrence, so a list that rejects mutation mid-way can end up with a duplicate
* but never without the listener at all.
*/
void moveSelfToFront(final List<ITestListener> listeners) {
try {
final int index = listeners.indexOf(this);
if (index > 0) {
listeners.add(0, this);
listeners.remove(index + 1);
}
} catch (RuntimeException e) {
reportListenerOrderProblem("the listener list rejected the change", e);
}
}

private static void reportListenerOrderProblem(final String reason, final RuntimeException exception) {
if (LISTENER_ORDER_PROBLEM_REPORTED.compareAndSet(false, true)) {
LOGGER.warn(
"Could not move the Allure listener to the front of the TestNG listener list: {}. "
+ "Attachments and steps added from custom test listeners (for example a screenshot "
+ "on failure) may be missing from the report. Please report an issue to "
+ "https://github.com/allure-framework/allure-java/issues "
+ "including your TestNG version",
reason,
exception
);
}
}

protected void createFakeResult(final ITestContext context, final ITestNGMethod method) {
final String uuid = UUID.randomUUID().toString();
final String parentUuid = UUID.randomUUID().toString();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
import io.qameta.allure.testfilter.TestPlan;
import io.qameta.allure.testfilter.TestPlanV1_0;
import io.qameta.allure.testng.config.AllureTestNgConfig;
import io.qameta.allure.testng.samples.CustomListenerAttachments;
import io.qameta.allure.testng.samples.PriorityTests;
import io.qameta.allure.testng.samples.TestsWithIdForFilter;
import org.assertj.core.api.Condition;
Expand All @@ -49,6 +50,7 @@
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.testng.ITestContext;
import org.testng.ITestListener;
import org.testng.ITestNGMethod;
import org.testng.ITestResult;
import org.testng.TestNG;
Expand All @@ -59,8 +61,10 @@

import java.lang.reflect.Method;
import java.net.URL;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
Expand All @@ -78,6 +82,7 @@
import static java.util.Arrays.asList;
import static java.util.Collections.singletonList;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.tuple;
import static org.junit.jupiter.params.provider.Arguments.arguments;
import static org.mockito.Mockito.mock;
Expand Down Expand Up @@ -1126,6 +1131,94 @@ public void attachmentsTest() {
.containsExactly("String attachment");
}

@AllureFeatures.Attachments
@Issue("1150")
@Test
@DisplayName("Should keep steps and attachments added by a custom listener on test failure")
public void shouldKeepCustomListenerEvidenceOnTestFailure() {
final AllureResults results = runTestNgSuites("suites/custom-listener-attachments.xml");

final TestResult failed = findTestResultByName(results, "failingTest");
assertThat(failed.getStatus())
.isEqualTo(Status.FAILED);
assertThat(failed.getSteps())
.extracting(StepResult::getName)
.containsExactly("On test start", "Take screenshot on failure", "Screenshot on failure");
assertThat(failed.getSteps())
.flatExtracting(StepResult::getAttachments)
.extracting(Attachment::getName)
.containsExactly("On test start", "Screenshot on failure");
}

@AllureFeatures.Attachments
@Issue("1150")
@Test
@DisplayName("Should keep attachments added by a custom listener on test start and success")
public void shouldKeepCustomListenerAttachmentsOnTestSuccess() {
final AllureResults results = runTestNgSuites("suites/custom-listener-attachments.xml");

final TestResult passed = findTestResultByName(results, "passingTest");
assertThat(passed.getStatus())
.isEqualTo(Status.PASSED);
assertThat(passed.getSteps())
.extracting(StepResult::getName)
.containsExactly("On test start", "On test success");
assertThat(passed.getSteps())
.flatExtracting(StepResult::getAttachments)
.extracting(Attachment::getName)
.containsExactly("On test start", "On test success");
}

@AllureFeatures.Attachments
@Issue("1150")
@Test
@DisplayName("Should keep attachments added by a custom listener registered via suite xml")
public void shouldKeepCustomListenerEvidenceWithXmlRegistration() {
final AllureResults results = runTestNgSuites("suites/custom-listener-attachments-xml.xml");

final TestResult failed = findTestResultByName(results, "failingTest");
assertThat(failed.getSteps())
.extracting(StepResult::getName)
.containsExactly("On test start", "Take screenshot on failure", "Screenshot on failure");

final TestResult passed = findTestResultByName(results, "passingTest");
assertThat(passed.getSteps())
.extracting(StepResult::getName)
.containsExactly("On test start", "On test success");
}

@AllureFeatures.Base
@Issue("1150")
@Test
@DisplayName("Should move the Allure listener to the front of the TestNG listener list")
public void shouldMoveAllureListenerToFront() {
final AllureTestNg adapter = new AllureTestNg(new AllureLifecycle(new AllureResultsWriterStub()));
final ITestListener custom = new CustomListenerAttachments.EvidenceListener();
final List<ITestListener> listeners = new ArrayList<>(Arrays.asList(custom, adapter));

adapter.moveSelfToFront(listeners);

assertThat(listeners)
.containsExactly(adapter, custom);
}

@AllureFeatures.Base
@Issue("1150")
@Test
@DisplayName("Should keep the default listener order when the TestNG listener list is unmodifiable")
public void shouldNotFailOnUnmodifiableListenerList() {
final AllureTestNg adapter = new AllureTestNg(new AllureLifecycle(new AllureResultsWriterStub()));
final ITestListener custom = new CustomListenerAttachments.EvidenceListener();
final List<ITestListener> listeners = Collections.unmodifiableList(
new ArrayList<>(Arrays.asList(custom, adapter))
);

assertThatCode(() -> adapter.moveSelfToFront(listeners))
.doesNotThrowAnyException();
assertThat(listeners)
.containsExactly(custom, adapter);
}

@AllureFeatures.MarkerAnnotations
@Issue("42")
@Test
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/*
* 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.testng.samples;

import io.qameta.allure.Allure;
import org.testng.ITestListener;
import org.testng.ITestResult;
import org.testng.annotations.Listeners;
import org.testng.annotations.Test;

/**
* Reproduces the popular screenshot-on-failure pattern: a custom listener registered via
* {@link Listeners} that reports evidence through the Allure API from test listener callbacks.
*/
@Listeners(CustomListenerAttachments.EvidenceListener.class)
public class CustomListenerAttachments {

@Test
public void passingTest() {
}

@Test
public void failingTest() {
throw new AssertionError("failed on purpose");
}

public static class EvidenceListener implements ITestListener {

@Override
public void onTestStart(final ITestResult result) {
Allure.attachment("On test start", "text/plain", "before " + result.getName());
}

@Override
public void onTestSuccess(final ITestResult result) {
Allure.attachment("On test success", "text/plain", "after passed " + result.getName());
}

@Override
public void onTestFailure(final ITestResult result) {
Allure.step("Take screenshot on failure");
Allure.attachment("Screenshot on failure", "text/plain", "after failed " + result.getName());
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/*
* 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.testng.samples;

import org.testng.annotations.Test;

/**
* The {@link CustomListenerAttachments} scenario wired through suite XML instead of the
* {@code @Listeners} annotation: the suite file registers
* {@link CustomListenerAttachments.EvidenceListener} via a {@code <listeners>} element.
*/
public class CustomListenerAttachmentsXml {

@Test
public void passingTest() {
}

@Test
public void failingTest() {
throw new AssertionError("failed on purpose");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE suite SYSTEM "http://testng.org/testng-1.0.dtd" >

<suite name="Custom listener attachments xml suite">
<listeners>
<listener class-name="io.qameta.allure.testng.samples.CustomListenerAttachments$EvidenceListener"/>
</listeners>
<test name="Custom listener attachments xml test">
<classes>
<class name="io.qameta.allure.testng.samples.CustomListenerAttachmentsXml">
</class>
</classes>
</test>
</suite>
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE suite SYSTEM "http://testng.org/testng-1.0.dtd" >

<suite name="Custom listener attachments suite">
<test name="Custom listener attachments test">
<classes>
<class name="io.qameta.allure.testng.samples.CustomListenerAttachments">
</class>
</classes>
</test>
</suite>
Loading