SmackIntegrationTestFramework.java

/**
 *
 * Copyright 2015-2023 Florian Schmaus
 *
 * 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 org.igniterealtime.smack.inttest;

import static org.reflections.ReflectionUtils.getAllMethods;
import static org.reflections.ReflectionUtils.withAnnotation;
import static org.reflections.ReflectionUtils.withModifier;
import static org.reflections.ReflectionUtils.withParametersCount;
import static org.reflections.ReflectionUtils.withReturnType;

import java.io.IOException;
import java.lang.annotation.Annotation;
import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.security.KeyManagementException;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.SortedSet;
import java.util.TreeSet;
import java.util.logging.Level;
import java.util.logging.Logger;

import org.jivesoftware.smack.AbstractXMPPConnection;
import org.jivesoftware.smack.ConnectionConfiguration;
import org.jivesoftware.smack.ConnectionConfiguration.SecurityMode;
import org.jivesoftware.smack.Smack;
import org.jivesoftware.smack.SmackConfiguration;
import org.jivesoftware.smack.SmackException;
import org.jivesoftware.smack.SmackException.NoResponseException;
import org.jivesoftware.smack.SmackException.NotConnectedException;
import org.jivesoftware.smack.XMPPException;
import org.jivesoftware.smack.XMPPException.XMPPErrorException;
import org.jivesoftware.smack.tcp.XMPPTCPConnectionConfiguration;
import org.jivesoftware.smack.util.StringUtils;
import org.jivesoftware.smack.util.TLSUtils;
import org.jivesoftware.smack.util.dns.dnsjava.DNSJavaResolver;
import org.jivesoftware.smack.util.dns.javax.JavaxResolver;
import org.jivesoftware.smack.util.dns.minidns.MiniDnsResolver;

import org.jivesoftware.smackx.debugger.EnhancedDebugger;
import org.jivesoftware.smackx.debugger.EnhancedDebuggerWindow;
import org.jivesoftware.smackx.iqregister.AccountManager;

import org.igniterealtime.smack.inttest.Configuration.AccountRegistration;
import org.igniterealtime.smack.inttest.annotations.AfterClass;
import org.igniterealtime.smack.inttest.annotations.BeforeClass;
import org.igniterealtime.smack.inttest.annotations.SmackIntegrationTest;
import org.igniterealtime.smack.inttest.annotations.SpecificationReference;
import org.reflections.Reflections;
import org.reflections.scanners.MethodAnnotationsScanner;
import org.reflections.scanners.MethodParameterScanner;
import org.reflections.scanners.SubTypesScanner;
import org.reflections.scanners.TypeAnnotationsScanner;

public class SmackIntegrationTestFramework {

    static {
        TLSUtils.setDefaultTrustStoreTypeToJksIfRequired();
    }

    private static final Logger LOGGER = Logger.getLogger(SmackIntegrationTestFramework.class.getName());

    public static boolean SINTTEST_UNIT_TEST = false;

    private static ConcreteTest TEST_UNDER_EXECUTION;

    protected final Configuration config;

    protected TestRunResult testRunResult;

    private SmackIntegrationTestEnvironment environment;
    protected XmppConnectionManager connectionManager;

    public enum TestType {
        Normal,
        LowLevel,
        SpecificLowLevel,
    }

    public static void main(String[] args) throws IOException, KeyManagementException,
            NoSuchAlgorithmException, SmackException, XMPPException, InterruptedException, InstantiationException,
            IllegalAccessException, IllegalArgumentException, InvocationTargetException {
        Configuration config = Configuration.newConfiguration(args);

        SmackIntegrationTestFramework sinttest = new SmackIntegrationTestFramework(config);
        TestRunResult testRunResult = sinttest.run();

        for (final TestRunResultProcessor testRunResultProcessor : config.testRunResultProcessors) {
            try {
                testRunResultProcessor.process(testRunResult);
            } catch (Throwable t) {
                LOGGER.log(Level.WARNING, "Invocation of TestRunResultProcessor " + testRunResultProcessor + " failed.", t);
            }
        }

        if (config.debuggerFactory instanceof EnhancedDebugger) {
            EnhancedDebuggerWindow.getInstance().waitUntilClosed();
        }

        final int exitStatus = testRunResult.failedIntegrationTests.isEmpty() ? 0 : 2;
        System.exit(exitStatus);
    }

    public static class JulTestRunResultProcessor implements TestRunResultProcessor {

        @Override
        public void process(final TestRunResult testRunResult) {
            for (Map.Entry<Class<? extends AbstractSmackIntTest>, Throwable> entry : testRunResult.impossibleTestClasses.entrySet()) {
                LOGGER.info("Could not run " + entry.getKey().getName() + " because: "
                    + entry.getValue().getLocalizedMessage());
            }
            for (TestNotPossible testNotPossible : testRunResult.impossibleIntegrationTests) {
                LOGGER.info("Could not run " + testNotPossible.concreteTest + " because: "
                    + testNotPossible.testNotPossibleException.getMessage());
            }
            for (SuccessfulTest successfulTest : testRunResult.successfulIntegrationTests) {
                LOGGER.info(successfulTest.concreteTest + " ✔");
            }
            final int successfulTests = testRunResult.successfulIntegrationTests.size();
            final int failedTests = testRunResult.failedIntegrationTests.size();
            final int availableTests = testRunResult.getNumberOfAvailableTests();
            LOGGER.info("SmackIntegrationTestFramework[" + testRunResult.testRunId + ']' + " finished: "
                + successfulTests + '/' + availableTests + " [" + failedTests + " failed]");

            if (failedTests > 0) {
                LOGGER.warning("💀 The following " + failedTests + " tests failed! 💀");
                final SortedSet<String> bySpecification = new TreeSet<>();
                for (FailedTest failedTest : testRunResult.failedIntegrationTests) {
                    final Throwable cause = failedTest.failureReason;
                    LOGGER.log(Level.SEVERE, failedTest.concreteTest + " failed: " + cause, cause);
                if (failedTest.concreteTest.method.getDeclaringClass().isAnnotationPresent(SpecificationReference.class)) {
                        final String specificationReference = getSpecificationReference(failedTest.concreteTest.method);
                        if (specificationReference != null) {
                            bySpecification.add("- " + specificationReference + " (as tested by '" + failedTest.concreteTest + "')");
                        }
                    }
                }
                if (!bySpecification.isEmpty()) {
                    LOGGER.log(Level.SEVERE, "The failed tests correspond to the following specifications:" + System.lineSeparator() + String.join(System.lineSeparator(), bySpecification));
                }
            } else {
                LOGGER.info("All possible Smack Integration Tests completed successfully. \\o/");
            }
        }
    }

    private static String getSpecificationReference(Method method) {
        final SpecificationReference spec = method.getDeclaringClass().getAnnotation(SpecificationReference.class);
        if (spec == null || spec.document().isBlank()) {
            return null;
        }
        String line = spec.document().trim();

        final SmackIntegrationTest test = method.getAnnotation(SmackIntegrationTest.class);
        if (!test.section().isBlank()) {
            line += " section " + test.section().trim();
        }
        if (!test.quote().isBlank()) {
            line += ":\t\"" + test.quote().trim() + "\"";
        }
        assert !line.isBlank();
        return line;
    }

    public SmackIntegrationTestFramework(Configuration configuration) {
        this.config = configuration;
    }

    public synchronized TestRunResult run()
            throws KeyManagementException, NoSuchAlgorithmException, SmackException, IOException, XMPPException,
            InterruptedException, InstantiationException, IllegalAccessException, IllegalArgumentException, InvocationTargetException {
        // The DNS resolver is not really a per sinttest run setting. It is not even a per connection setting. Instead
        // it is a global setting, but we treat it like a per sinttest run setting.
        switch (config.dnsResolver) {
        case minidns:
            MiniDnsResolver.setup();
            break;
        case javax:
            JavaxResolver.setup();
            break;
        case dnsjava:
            DNSJavaResolver.setup();
            break;
        }
        testRunResult = new TestRunResult();

        // Create a connection manager *after* we created the testRunId (in testRunResult).
        this.connectionManager = new XmppConnectionManager(this);

        LOGGER.info("SmackIntegrationTestFramework [" + testRunResult.testRunId + ']' + ": Starting\nSmack version: " + Smack.getVersion());
        if (config.debuggerFactory != null) {
            // JUL Debugger will not print any information until configured to print log messages of
            // level FINE
            // TODO configure JUL for log?
            SmackConfiguration.addDisabledSmackClass("org.jivesoftware.smack.debugger.JulDebugger");
            SmackConfiguration.DEBUG = true;
        }
        if (config.replyTimeout > 0) {
            SmackConfiguration.setDefaultReplyTimeout(config.replyTimeout);
        }
        if (config.securityMode != SecurityMode.required && config.accountRegistration == AccountRegistration.inBandRegistration) {
            AccountManager.sensitiveOperationOverInsecureConnectionDefault(true);
        }
        // TODO print effective configuration

        String[] testPackages;
        if (config.testPackages == null || config.testPackages.isEmpty()) {
            testPackages = new String[] { "org.jivesoftware.smackx", "org.jivesoftware.smack" };
        }
        else {
            testPackages = config.testPackages.toArray(new String[config.testPackages.size()]);
        }
        Reflections reflections = new Reflections(testPackages, new SubTypesScanner(),
                        new TypeAnnotationsScanner(), new MethodAnnotationsScanner(), new MethodParameterScanner());
        Set<Class<? extends AbstractSmackIntegrationTest>> inttestClasses = reflections.getSubTypesOf(AbstractSmackIntegrationTest.class);
        Set<Class<? extends AbstractSmackLowLevelIntegrationTest>> lowLevelInttestClasses = reflections.getSubTypesOf(AbstractSmackLowLevelIntegrationTest.class);

        final int builtInTestCount = inttestClasses.size() + lowLevelInttestClasses.size();
        Set<Class<? extends AbstractSmackIntTest>> classes = new HashSet<>(builtInTestCount);
        classes.addAll(inttestClasses);
        classes.addAll(lowLevelInttestClasses);

        {
            // Remove all abstract classes.
            // TODO: This may be a good candidate for Java stream filtering once Smack is Android API 24 or higher.
            Iterator<Class<? extends AbstractSmackIntTest>> it = classes.iterator();
            while (it.hasNext()) {
                Class<? extends AbstractSmackIntTest> clazz = it.next();
                if (Modifier.isAbstract(clazz.getModifiers())) {
                    it.remove();
                }
            }
        }

        if (classes.isEmpty()) {
            throw new IllegalStateException("No test classes in " + Arrays.toString(testPackages) + " found");
        }

        LOGGER.info("SmackIntegrationTestFramework [" + testRunResult.testRunId
                        + "]: Finished scanning for tests, preparing environment\n"
                        + "\tJava SE Platform version: " + Runtime.version());
        environment = prepareEnvironment();

        try {
            runTests(classes);
        }
        catch (Throwable t) {
            // Log the thrown Throwable to prevent it being shadowed in case the finally block below also throws.
            LOGGER.log(Level.SEVERE, "Unexpected abort because runTests() threw throwable", t);
            throw t;
        }
        finally {
            // Ensure that the accounts are deleted and disconnected before we continue
            connectionManager.disconnectAndCleanup();
        }

        return testRunResult;
    }

    public static ConcreteTest getTestUnderExecution() {
        return TEST_UNDER_EXECUTION;
    }

    @SuppressWarnings({"Finally"})
    private void runTests(Set<Class<? extends AbstractSmackIntTest>> classes)
            throws InterruptedException, InstantiationException, IllegalAccessException,
            IllegalArgumentException, SmackException, IOException, XMPPException {
        List<PreparedTest> tests = new ArrayList<>(classes.size());
        int numberOfAvailableTests = 0;

        for (Class<? extends AbstractSmackIntTest> testClass : classes) {
            final String testClassName = testClass.getName();

            // TODO: Move the whole "skipping section" below one layer up?

            // Skip pseudo integration tests from src/test
            // Although Smack's gradle build files do not state that the 'main' sources classpath also contains the
            // 'test' classes. Some IDEs like Eclipse include them. As result, a real integration test run encounters
            // pseudo integration tests like the DummySmackIntegrationTest which always throws from src/test.
            // It is unclear why this apparently does not happen in the 4.3 branch, one likely cause is
            // compile project(path: ":smack-omemo", configuration: "testRuntime")
            // in
            // smack-integration-test/build.gradle:17
            // added after 4.3 was branched out with
            // 1f731f6318785a84b9741280d586a61dc37ecb2e
            // Now "gradle integrationTest" appear to be never affected by this, i.e., they are executed with the
            // correct classpath. Plain Eclipse, i.e. Smack imported into Eclipse after "gradle eclipse", appear
            // to include *all* classes. Which means those runs sooner or later try to execute
            // DummySmackIntegrationTest. Eclipse with buildship, the gradle plugin for Eclipse, always excludes
            // *all* src/test classes, which means they do not encounter DummySmackIntegrationTest, but this means
            // that the "compile project(path: ":smack-omemo", configuration: "testRuntime")" is not respected,
            // which leads to
            // Exception in thread "main" java.lang.NoClassDefFoundError: org/jivesoftware/smack/test/util/FileTestUtil
            //   at org.jivesoftware.smackx.ox.OXSecretKeyBackupIntegrationTest.<clinit>(OXSecretKeyBackupIntegrationTest.java:66)
            // See
            // - https://github.com/eclipse/buildship/issues/354 (Remove test dependencies from runtime classpath)
            // - https://bugs.eclipse.org/bugs/show_bug.cgi?id=482315 (Runtime classpath includes test dependencies)
            // - https://discuss.gradle.org/t/main-vs-test-compile-vs-runtime-classpaths-in-eclipse-once-and-for-all-how/17403
            // - https://bugs.eclipse.org/bugs/show_bug.cgi?id=376616 (Scope of dependencies has no effect on Eclipse compilation)
            if (!SINTTEST_UNIT_TEST && testClassName.startsWith("org.igniterealtime.smack.inttest.unittest")) {
                LOGGER.warning("Skipping integration test '" + testClassName + "' from src/test classpath (should not be in classpath)");
                continue;
            }

            if (!config.isClassEnabled(testClass)) {
                DisabledTestClass disabledTestClass = new DisabledTestClass(testClass, "Skipping test class " + testClassName + " because it is not enabled");
                testRunResult.disabledTestClasses.add(disabledTestClass);
                continue;
            }

            if (config.isClassDisabled(testClass)) {
                DisabledTestClass disabledTestClass = new DisabledTestClass(testClass, "Skipping test class " + testClassName + " because it is disabled");
                testRunResult.disabledTestClasses.add(disabledTestClass);
                continue;
            }

            final String specification;
            if (testClass.isAnnotationPresent(SpecificationReference.class)) {
                final SpecificationReference specificationReferenceAnnotation = testClass.getAnnotation(SpecificationReference.class);
                specification = Configuration.normalizeSpecification(specificationReferenceAnnotation.document());
            } else {
                specification = null;
            }

            if (!config.isSpecificationEnabled(specification)) {
                DisabledTestClass disabledTestClass = new DisabledTestClass(testClass, "Skipping test method " + testClass + " because it tests a specification ('" + specification + "') that is not enabled");
                testRunResult.disabledTestClasses.add(disabledTestClass);
                continue;
            }

            if (config.isSpecificationDisabled(specification)) {
                DisabledTestClass disabledTestClass = new DisabledTestClass(testClass, "Skipping test method " + testClass + " because it tests a specification ('" + specification + "') that is disabled");
                testRunResult.disabledTestClasses.add(disabledTestClass);
                continue;
            }

            final Constructor<? extends AbstractSmackIntTest> cons;
            try {
                cons = testClass.getConstructor(SmackIntegrationTestEnvironment.class);
            }
            catch (NoSuchMethodException | SecurityException e) {
                throw new IllegalArgumentException(
                                "Smack Integration Test class does not declare the correct constructor. Is a public Constructor(SmackIntegrationTestEnvironment) missing?",
                                e);
            }

            final List<Method> smackIntegrationTestMethods;
            {
                Method[] testClassMethods = testClass.getMethods();
                smackIntegrationTestMethods = new ArrayList<>(testClassMethods.length);
                for (Method method : testClassMethods) {
                    if (!method.isAnnotationPresent(SmackIntegrationTest.class)) {
                        continue;
                    }
                    smackIntegrationTestMethods.add(method);
                }
            }

            if (smackIntegrationTestMethods.isEmpty()) {
                LOGGER.warning("No Smack integration test methods found in " + testClass);
                continue;
            }

            final AbstractSmackIntTest test;
            try {
                test = cons.newInstance(environment);
            }
            catch (InvocationTargetException e) {
                Throwable cause = e.getCause();

                throwFatalException(cause);

                testRunResult.impossibleTestClasses.put(testClass, cause);
                continue;
            }

            XmppConnectionDescriptor<?, ?, ?> specificLowLevelConnectionDescriptor = null;
            final TestType testType;
            if (test instanceof AbstractSmackSpecificLowLevelIntegrationTest) {
                AbstractSmackSpecificLowLevelIntegrationTest<?> specificLowLevelTest = (AbstractSmackSpecificLowLevelIntegrationTest<?>) test;
                specificLowLevelConnectionDescriptor = specificLowLevelTest.getConnectionDescriptor();
                testType = TestType.SpecificLowLevel;
            } else if (test instanceof AbstractSmackLowLevelIntegrationTest) {
                testType = TestType.LowLevel;
            } else if (test instanceof AbstractSmackIntegrationTest) {
                testType = TestType.Normal;
            } else {
                throw new AssertionError();
            }

            // Verify the method signatures, throw in case a signature is incorrect.
            for (Method method : smackIntegrationTestMethods) {
                Class<?> retClass = method.getReturnType();
                if (!retClass.equals(Void.TYPE)) {
                    throw new IllegalStateException(
                            "SmackIntegrationTest annotation on" + method + " that does not return void");
                }
                switch (testType) {
                case Normal:
                    final Class<?>[] parameterTypes = method.getParameterTypes();
                    if (parameterTypes.length > 0) {
                        throw new IllegalStateException(
                                "SmackIntegrationTest annotaton on " + method + " that takes arguments ");
                    }
                    break;
                case LowLevel:
                    verifyLowLevelTestMethod(method, AbstractXMPPConnection.class);
                    break;
                case SpecificLowLevel:
                    Class<? extends AbstractXMPPConnection> specificLowLevelConnectionClass = specificLowLevelConnectionDescriptor.getConnectionClass();
                    verifyLowLevelTestMethod(method, specificLowLevelConnectionClass);
                    break;
                }
            }

            Iterator<Method> it = smackIntegrationTestMethods.iterator();
            while (it.hasNext()) {
                final Method method = it.next();
                final String methodName = method.getName();
                if (!config.isMethodEnabled(method)) {
                    DisabledTest disabledTest = new DisabledTest(method, "Skipping test method " + methodName + " because it is not enabled");
                    testRunResult.disabledTests.add(disabledTest);
                    it.remove();
                    continue;
                }
                if (config.isMethodDisabled(method)) {
                    DisabledTest disabledTest = new DisabledTest(method, "Skipping test method " + methodName + " because it is disabled");
                    testRunResult.disabledTests.add(disabledTest);
                    it.remove();
                    continue;
                }
            }

            if (smackIntegrationTestMethods.isEmpty()) {
                LOGGER.info("All tests in " + testClassName + " are disabled");
                continue;
            }

            List<ConcreteTest> concreteTests = new ArrayList<>(smackIntegrationTestMethods.size());

            for (Method testMethod : smackIntegrationTestMethods) {
                switch (testType) {
                case Normal: {
                    ConcreteTest.Executor concreteTestExecutor = () -> testMethod.invoke(test);
                    ConcreteTest concreteTest = new ConcreteTest(testType, testMethod, concreteTestExecutor);
                    concreteTests.add(concreteTest);
                }
                    break;
                case LowLevel:
                case SpecificLowLevel:
                    LowLevelTestMethod lowLevelTestMethod = new LowLevelTestMethod(testMethod);
                    switch (testType) {
                    case LowLevel:
                        List<ConcreteTest> concreteLowLevelTests = invokeLowLevel(lowLevelTestMethod, (AbstractSmackLowLevelIntegrationTest) test);
                        concreteTests.addAll(concreteLowLevelTests);
                        break;
                    case SpecificLowLevel: {
                        ConcreteTest.Executor concreteTestExecutor = () -> invokeSpecificLowLevel(
                                lowLevelTestMethod, (AbstractSmackSpecificLowLevelIntegrationTest<?>) test);
                        ConcreteTest concreteTest = new ConcreteTest(testType, testMethod, concreteTestExecutor);
                        concreteTests.add(concreteTest);
                        break;
                    }
                    default:
                        throw new AssertionError();
                    }
                    break;
                }
            }

            // Instantiate the prepared test early as this will check the before and after class annotations.
            PreparedTest preparedTest = new PreparedTest(test, concreteTests);
            tests.add(preparedTest);

            numberOfAvailableTests += concreteTests.size();
        }

        // Print status information.
        StringBuilder sb = new StringBuilder(1024);
        sb.append("Smack Integration Test Framework\n");
        sb.append("################################\n");
        if (config.verbose) {
            sb.append('\n');
            if (!testRunResult.disabledTestClasses.isEmpty()) {
                sb.append("The following test classes are disabled:\n");
                for (DisabledTestClass disabledTestClass : testRunResult.disabledTestClasses) {
                    disabledTestClass.appendTo(sb).append('\n');
                }
            }
            if (!testRunResult.disabledTests.isEmpty()) {
                sb.append("The following tests are disabled:\n");
                for (DisabledTest disabledTest : testRunResult.disabledTests) {
                    disabledTest.appendTo(sb).append('\n');
                }
            }
            sb.append('\n');
        }
        sb.append("Available tests: ").append(numberOfAvailableTests);
        if (!testRunResult.disabledTestClasses.isEmpty() || !testRunResult.disabledTests.isEmpty()) {
            sb.append(" (Disabled ").append(testRunResult.disabledTestClasses.size()).append(" classes")
              .append(" and ").append(testRunResult.disabledTests.size()).append(" tests)");
        }
        sb.append('\n');
        LOGGER.info(sb.toString());

        for (PreparedTest test : tests) {
            test.run();
        }

        // Assert that all tests in the 'tests' list produced a result.
        assert numberOfAvailableTests == testRunResult.getNumberOfAvailableTests();
    }

    private void runConcreteTest(ConcreteTest concreteTest)
            throws InterruptedException, XMPPException, IOException, SmackException {
        LOGGER.info(concreteTest + " Start");
        long testStart = System.currentTimeMillis();
        try {
            concreteTest.executor.execute();
            long testEnd = System.currentTimeMillis();
            LOGGER.info(concreteTest + " Success");
            testRunResult.successfulIntegrationTests.add(new SuccessfulTest(concreteTest, testStart, testEnd, null));
        }
        catch (InvocationTargetException e) {
            long testEnd = System.currentTimeMillis();
            Throwable cause = e.getCause();
            if (cause instanceof TestNotPossibleException) {
                LOGGER.info(concreteTest + " is not possible");
                testRunResult.impossibleIntegrationTests.add(new TestNotPossible(concreteTest, testStart, testEnd,
                                null, (TestNotPossibleException) cause));
                return;
            }
            Throwable nonFatalFailureReason;
            // junit asserts throw an AssertionError if they fail, those should not be
            // thrown up, as it would be done by throwFatalException()
            if (cause instanceof AssertionError) {
                nonFatalFailureReason = cause;
            } else {
                nonFatalFailureReason = throwFatalException(cause);
            }
            // An integration test failed
            testRunResult.failedIntegrationTests.add(new FailedTest(concreteTest, testStart, testEnd, null,
                            nonFatalFailureReason));
            LOGGER.log(Level.SEVERE, concreteTest + " Failed", e);
        }
        catch (IllegalArgumentException | IllegalAccessException e) {
            throw new AssertionError(e);
        }
    }

    private static void verifyLowLevelTestMethod(Method method,
                    Class<? extends AbstractXMPPConnection> connectionClass) {
        if (determineTestMethodParameterType(method, connectionClass) != null) {
            return;
        }

        throw new IllegalArgumentException(method + " is not a valid low level test method");
    }

    private List<ConcreteTest> invokeLowLevel(LowLevelTestMethod lowLevelTestMethod, AbstractSmackLowLevelIntegrationTest test) {
        Collection<? extends XmppConnectionDescriptor<?, ?, ?>> connectionDescriptors;
        if (lowLevelTestMethod.smackIntegrationTestAnnotation.onlyDefaultConnectionType()) {
            XmppConnectionDescriptor<?, ?, ?> defaultConnectionDescriptor = connectionManager.getDefaultConnectionDescriptor();
            connectionDescriptors = Collections.singleton(defaultConnectionDescriptor);
        } else {
            connectionDescriptors = connectionManager.getConnectionDescriptors();
        }

        List<ConcreteTest> resultingConcreteTests = new ArrayList<>(connectionDescriptors.size());

        for (XmppConnectionDescriptor<?, ?, ?> connectionDescriptor : connectionDescriptors) {
            String connectionNick = connectionDescriptor.getNickname();

            if (config.enabledConnections != null && !config.enabledConnections.contains(connectionNick)) {
                DisabledTest disabledTest = new DisabledTest(lowLevelTestMethod.testMethod, "Not creating test for " + lowLevelTestMethod + " with connection '" + connectionNick
                                + "', as this connection type is not enabled");
                testRunResult.disabledTests.add(disabledTest);
                continue;
            }

            if (config.disabledConnections != null && config.disabledConnections.contains(connectionNick)) {
                DisabledTest disabledTest = new DisabledTest(lowLevelTestMethod.testMethod, "Not creating test for " + lowLevelTestMethod + " with connection '" + connectionNick
                                + ", as this connection type is disabled");
                testRunResult.disabledTests.add(disabledTest);
                continue;
            }

            ConcreteTest.Executor executor = () -> lowLevelTestMethod.invoke(test, connectionDescriptor);
            ConcreteTest concreteTest = new ConcreteTest(TestType.LowLevel, lowLevelTestMethod.testMethod, executor, connectionDescriptor.getNickname());
            resultingConcreteTests.add(concreteTest);
        }

        return resultingConcreteTests;
    }

    private static <C extends AbstractXMPPConnection> void invokeSpecificLowLevel(LowLevelTestMethod testMethod,
            AbstractSmackSpecificLowLevelIntegrationTest<C> test)
            throws IllegalAccessException, IllegalArgumentException, InvocationTargetException, InterruptedException,
            SmackException, IOException, XMPPException {
        if (testMethod.smackIntegrationTestAnnotation.onlyDefaultConnectionType()) {
            throw new IllegalArgumentException("SpecificLowLevelTests must not have set onlyDefaultConnectionType");
        }

        XmppConnectionDescriptor<C, ? extends ConnectionConfiguration, ? extends ConnectionConfiguration.Builder<?, ?>> connectionDescriptor = test.getConnectionDescriptor();
        testMethod.invoke(test, connectionDescriptor);
    }

    protected SmackIntegrationTestEnvironment prepareEnvironment() throws SmackException,
                    IOException, XMPPException, InterruptedException, KeyManagementException,
                    NoSuchAlgorithmException, InstantiationException, IllegalAccessException, IllegalArgumentException, InvocationTargetException {
        return connectionManager.prepareEnvironment();
    }

    enum AccountNum {
        One,
        Two,
        Three,
    }

    static XMPPTCPConnectionConfiguration.Builder getConnectionConfigurationBuilder(Configuration config) {
        XMPPTCPConnectionConfiguration.Builder builder = XMPPTCPConnectionConfiguration.builder();

        config.configurationApplier.applyConfigurationTo(builder);

        return builder;
    }

    private static Exception throwFatalException(Throwable e) throws Error, NoResponseException,
                    InterruptedException {
        if (e instanceof InterruptedException) {
            throw (InterruptedException) e;
        }

        // We handle NullPointerException as a non-fatal exception, as they are mostly caused by an invalid reply where
        // an extension element is missing. Consider for example
        // assertEquals(StanzaError.Condition.foo, response.getError().getCondition())
        // Otherwise NPEs could be caused by an internal bug in Smack, e.g. missing null handling.
        if (e instanceof NullPointerException) {
            return (NullPointerException) e;
        }
        if (e instanceof RuntimeException) {
            throw (RuntimeException) e;
        }
        if (e instanceof Error) {
            throw (Error) e;
        }
        return (Exception) e;
    }

    @FunctionalInterface
    public interface TestRunResultProcessor {
        void process(SmackIntegrationTestFramework.TestRunResult testRunResult);
    }

    public static final class TestRunResult {

        /**
         * A short String of lowercase characters and numbers used to identify an integration test
         * run. We use lowercase characters because this string will eventually be part of the
         * localpart of the used JIDs (and the localpart is case-insensitive).
         */
        public final String testRunId = StringUtils.insecureRandomString(5).toLowerCase(Locale.US);

        private final List<SuccessfulTest> successfulIntegrationTests = Collections.synchronizedList(new LinkedList<SuccessfulTest>());
        private final List<FailedTest> failedIntegrationTests = Collections.synchronizedList(new LinkedList<FailedTest>());
        private final List<TestNotPossible> impossibleIntegrationTests = Collections.synchronizedList(new LinkedList<TestNotPossible>());

        // TODO: Ideally three would only be a list of disabledTests, but since we do not process a disabled test class
        // any further, we can not determine the concrete disabled tests.
        private final List<DisabledTestClass> disabledTestClasses = Collections.synchronizedList(new ArrayList<>());
        private final List<DisabledTest> disabledTests = Collections.synchronizedList(new ArrayList<>());

        private final Map<Class<? extends AbstractSmackIntTest>, Throwable> impossibleTestClasses = new HashMap<>();

        TestRunResult() {
        }

        public String getTestRunId() {
            return testRunId;
        }

        public int getNumberOfAvailableTests() {
            return successfulIntegrationTests.size() + failedIntegrationTests.size() + impossibleIntegrationTests.size();
        }

        public List<SuccessfulTest> getSuccessfulTests() {
            return Collections.unmodifiableList(successfulIntegrationTests);
        }

        public List<FailedTest> getFailedTests() {
            return Collections.unmodifiableList(failedIntegrationTests);
        }

        public List<TestNotPossible> getNotPossibleTests() {
            return Collections.unmodifiableList(impossibleIntegrationTests);
        }

        public Map<Class<? extends AbstractSmackIntTest>, Throwable> getImpossibleTestClasses() {
            return Collections.unmodifiableMap(impossibleTestClasses);
        }
    }

    final class PreparedTest {
        private final AbstractSmackIntTest test;
        private final List<ConcreteTest> concreteTests;

        private final Method beforeClassMethod;
        private final Method afterClassMethod;

        private PreparedTest(AbstractSmackIntTest test, List<ConcreteTest> concreteTests) {
            this.test = test;
            this.concreteTests = concreteTests;
            Class<? extends AbstractSmackIntTest> testClass = test.getClass();

            beforeClassMethod = getSinttestSpecialMethod(testClass, BeforeClass.class);
            afterClassMethod = getSinttestSpecialMethod(testClass, AfterClass.class);
        }

        public void run() throws InterruptedException, XMPPException, IOException, SmackException {
            try {
                // Run the @BeforeClass methods (if any)
                executeSinttestSpecialMethod(beforeClassMethod);

                for (ConcreteTest concreteTest : concreteTests) {
                    TEST_UNDER_EXECUTION = concreteTest;
                    try {
                        runConcreteTest(concreteTest);
                    } finally {
                        TEST_UNDER_EXECUTION = null;
                    }
                }
            }
            finally {
                executeSinttestSpecialMethod(afterClassMethod);
            }
        }

        private void executeSinttestSpecialMethod(Method method) {
            if (method == null) {
                return;
            }

            try {
                method.invoke(test);
            }
            catch (InvocationTargetException | IllegalAccessException e) {
                LOGGER.log(Level.SEVERE, "Exception executing " + method, e);
            }
            catch (IllegalArgumentException e) {
                throw new AssertionError(e);
            }
        }
    }

    @SuppressWarnings("unchecked")
    private static Method getSinttestSpecialMethod(Class<? extends AbstractSmackIntTest> testClass, Class<? extends Annotation> annotation) {
        Set<Method> specialClassMethods = getAllMethods(testClass,
                        withAnnotation(annotation), withReturnType(Void.TYPE),
                        withParametersCount(0), withModifier(Modifier.PUBLIC
                                        ));

        // See if there are any methods that have a special but a wrong signature
        Set<Method> allSpecialClassMethods = getAllMethods(testClass, withAnnotation(annotation));
        allSpecialClassMethods.removeAll(specialClassMethods);
        if (!allSpecialClassMethods.isEmpty()) {
            throw new IllegalArgumentException(annotation + " methods with wrong signature found");
        }

        if (specialClassMethods.size() == 1) {
            return specialClassMethods.iterator().next();
        }
        else if (specialClassMethods.size() > 1) {
            throw new IllegalArgumentException("Only one @BeforeClass method allowed");
        }

        return null;
    }

    public static final class ConcreteTest {
        private final TestType testType;
        private final Method method;
        private final Executor executor;
        private final List<String> subdescriptons;

        private ConcreteTest(TestType testType, Method method, Executor executor, String... subdescriptions) {
            this.testType = testType;
            this.method = method;
            this.executor = executor;
            this.subdescriptons = List.of(subdescriptions);
        }

        private transient String stringCache;

        public TestType getTestType() {
            return testType;
        }

        public Method getMethod() {
            return method;
        }

        public List<String> getSubdescriptons() {
            return subdescriptons;
        }

        @Override
        public String toString() {
            if (stringCache != null) {
                return stringCache;
            }

            StringBuilder sb = new StringBuilder();
            sb.append(method.getDeclaringClass().getSimpleName())
                .append('.')
                .append(method.getName())
                .append(" (")
                .append(testType.name());
            if (!subdescriptons.isEmpty()) {
                sb.append(", ");
                StringUtils.appendTo(subdescriptons, sb);
            }
            sb.append(')');

            stringCache = sb.toString();
            return stringCache;
        }

        private interface Executor {

            /**
             * Execute the test.
             *
             * @throws IllegalAccessException if there was an illegal access.
             * @throws InterruptedException if the calling thread was interrupted.
             * @throws InvocationTargetException if the reflective invoked test throws an exception.
             * @throws XMPPException in case an XMPPException happens when <em>preparing</em> the test.
             * @throws IOException in case an IOException happens when <em>preparing</em> the test.
             * @throws SmackException in case an SmackException happens when <em>preparing</em> the test.
             */
            void execute() throws IllegalAccessException, InterruptedException, InvocationTargetException,
                    XMPPException, IOException, SmackException;
        }
    }

    public static final class DisabledTestClass {
        private final Class<? extends AbstractSmackIntTest> testClass;
        private final String reason;

        private DisabledTestClass(Class<? extends AbstractSmackIntTest> testClass, String reason) {
            this.testClass = testClass;
            this.reason = reason;
        }

        public Class<? extends AbstractSmackIntTest> getTestClass() {
            return testClass;
        }

        public String getReason() {
            return reason;
        }

        public StringBuilder appendTo(StringBuilder sb) {
            return sb.append("Disabled ").append(testClass).append(" because ").append(reason);
        }
    }

    public static final class DisabledTest {
        private final Method method;
        private final String reason;

        private DisabledTest(Method method, String reason) {
            this.method = method;
            this.reason = reason;
        }

        public Method getMethod() {
            return method;
        }

        public String getReason() {
            return reason;
        }

        public StringBuilder appendTo(StringBuilder sb) {
            return sb.append("Disabled ").append(method).append(" because ").append(reason);
        }
    }

    private final class LowLevelTestMethod {

        private final Method testMethod;
        private final SmackIntegrationTest smackIntegrationTestAnnotation;
        private final TestMethodParameterType parameterType;

        private LowLevelTestMethod(Method testMethod) {
            this.testMethod = testMethod;

            smackIntegrationTestAnnotation = testMethod.getAnnotation(SmackIntegrationTest.class);
            assert smackIntegrationTestAnnotation != null;
            parameterType = determineTestMethodParameterType(testMethod);
        }

        private void invoke(AbstractSmackLowLevelIntegrationTest test,
                        XmppConnectionDescriptor<?, ?, ?> connectionDescriptor)
                        throws IllegalAccessException, IllegalArgumentException, InvocationTargetException,
                        InterruptedException, SmackException, IOException, XMPPException {
            switch (parameterType) {
            case singleConnectedConnection:
            case collectionOfConnections:
            case parameterListOfConnections:
                final boolean collectionOfConnections = parameterType == TestMethodParameterType.collectionOfConnections;

                final int connectionCount;
                if (collectionOfConnections) {
                    connectionCount = smackIntegrationTestAnnotation.connectionCount();
                    if (connectionCount < 1) {
                        throw new IllegalArgumentException(testMethod + " is annotated to use less than one connection ('"
                                        + connectionCount + ')');
                    }
                } else {
                    connectionCount = testMethod.getParameterCount();
                }

                List<? extends AbstractXMPPConnection> connections = connectionManager.constructConnectedConnections(
                                connectionDescriptor, connectionCount);

                if (collectionOfConnections) {
                    testMethod.invoke(test, connections);
                } else {
                    Object[] connectionsArray = new Object[connectionCount];
                    for (int i = 0; i < connectionsArray.length; i++) {
                        connectionsArray[i] = connections.remove(0);
                    }
                    testMethod.invoke(test, connectionsArray);
                }

                connectionManager.recycle(connections);
                break;
            case unconnectedConnectionSource:
                AbstractSmackLowLevelIntegrationTest.UnconnectedConnectionSource source = () -> {
                    try {
                        return environment.connectionManager.constructConnection(connectionDescriptor);
                    } catch (NoResponseException | XMPPErrorException | NotConnectedException
                                    | InterruptedException e) {
                        // TODO: Ideally we would wrap the exceptions in an unchecked exceptions, catch those unchecked
                        // exceptions below and throw the wrapped checked exception.
                        throw new RuntimeException(e);
                    }
                };
                testMethod.invoke(test, source);
                break;
            case noParamSpecificLowLevel:
                testMethod.invoke(test);
                break;
            }
        }

        @Override
        public String toString() {
            return testMethod.toString();
        }
    }

    enum TestMethodParameterType {
        /**
         * testMethod(Connection connection)
         */
        singleConnectedConnection,

        /**
         * testMethod(Collection&lt;Connection&gt;)
         * <p> It can also be a subclass of collection like List. In fact, the type of the parameter being List is expected to be the common case.
         */
        collectionOfConnections,

        /**
         * testMethod(Connection a, Connection b, Connection c)
         */
        parameterListOfConnections,

        /**
         * testMethod(UnconnectedConnectionSource unconnectedConnectionSource)
         */
        unconnectedConnectionSource,

        /**
         * A no-parameter method of a {@link AbstractSmackSpecificLowLevelIntegrationTest}.
         */
        noParamSpecificLowLevel,
    };

    static TestMethodParameterType determineTestMethodParameterType(Method testMethod) {
        return determineTestMethodParameterType(testMethod, AbstractXMPPConnection.class);
    }

    static TestMethodParameterType determineTestMethodParameterType(Method testMethod, Class<? extends AbstractXMPPConnection> connectionClass) {
        Class<?>[] parameterTypes = testMethod.getParameterTypes();
        if (parameterTypes.length == 0) {
            if (AbstractSmackSpecificLowLevelIntegrationTest.class.isAssignableFrom(testMethod.getDeclaringClass())) {
                return TestMethodParameterType.noParamSpecificLowLevel;
            }
            return null;
        }

        if (parameterTypes.length > 1) {
            // If there are more parameters, then all must be assignable from the connection class.
            for (Class<?> parameterType : parameterTypes) {
                if (!connectionClass.isAssignableFrom(parameterType)) {
                    return null;
                }
            }

            return TestMethodParameterType.parameterListOfConnections;
        }

        // This method has exactly a single parameter.
        Class<?> soleParameter = parameterTypes[0];

        if (Collection.class.isAssignableFrom(soleParameter)) {
            // The sole parameter is assignable from collection, which means that it is a parameterized generic type.
            ParameterizedType soleParameterizedType = (ParameterizedType) testMethod.getGenericParameterTypes()[0];
            Type[] actualTypeArguments = soleParameterizedType.getActualTypeArguments();
            if (actualTypeArguments.length != 1) {
                // The parameter list of the Collection has more than one type.
                return null;
            }

            Type soleActualTypeArgument = actualTypeArguments[0];
            if (!(soleActualTypeArgument instanceof Class<?>)) {
                return null;
            }

            Class<?> soleActualTypeArgumentAsClass = (Class<?>) soleActualTypeArgument;
            if (!connectionClass.isAssignableFrom(soleActualTypeArgumentAsClass)) {
                return null;
            }

            return TestMethodParameterType.collectionOfConnections;
        } else if (connectionClass.isAssignableFrom(soleParameter)) {
            return TestMethodParameterType.singleConnectedConnection;
        } else if (AbstractSmackLowLevelIntegrationTest.UnconnectedConnectionSource.class.isAssignableFrom(soleParameter)) {
            return TestMethodParameterType.unconnectedConnectionSource;
        }

        return null;
    }

}