001/*
002 * JDrupes Builder
003 * Copyright (C) 2025 Michael N. Lipp
004 * 
005 * This program is free software: you can redistribute it and/or modify
006 * it under the terms of the GNU Affero General Public License as
007 * published by the Free Software Foundation, either version 3 of the
008 * License, or (at your option) any later version.
009 *
010 * This program is distributed in the hope that it will be useful,
011 * but WITHOUT ANY WARRANTY; without even the implied warranty of
012 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
013 * GNU Affero General Public License for more details.
014 *
015 * You should have received a copy of the GNU Affero General Public License
016 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
017 */
018
019package org.jdrupes.builder.junit;
020
021import com.google.common.flogger.FluentLogger;
022import static com.google.common.flogger.LazyArgs.*;
023import java.io.File;
024import java.io.IOException;
025import java.net.MalformedURLException;
026import java.net.URL;
027import java.net.URLClassLoader;
028import java.nio.file.Path;
029import java.util.HashSet;
030import java.util.Objects;
031import java.util.Set;
032import java.util.stream.Collectors;
033import java.util.stream.Stream;
034import org.jdrupes.builder.api.Generator;
035import org.jdrupes.builder.api.Intent;
036import static org.jdrupes.builder.api.Intent.*;
037import org.jdrupes.builder.api.MergedTestProject;
038import org.jdrupes.builder.api.Project;
039import org.jdrupes.builder.api.Resource;
040import org.jdrupes.builder.api.ResourceRequest;
041import org.jdrupes.builder.api.ResourceType;
042import static org.jdrupes.builder.api.ResourceType.*;
043import org.jdrupes.builder.api.TestResult;
044import org.jdrupes.builder.core.AbstractGenerator;
045import org.jdrupes.builder.java.ClassTree;
046import org.jdrupes.builder.java.ClasspathElement;
047import static org.jdrupes.builder.java.JavaTypes.*;
048import org.junit.platform.engine.TestDescriptor.Type;
049import org.junit.platform.engine.TestExecutionResult;
050import org.junit.platform.engine.TestExecutionResult.Status;
051import org.junit.platform.engine.discovery.DiscoverySelectors;
052import org.junit.platform.engine.reporting.FileEntry;
053import org.junit.platform.engine.reporting.ReportEntry;
054import org.junit.platform.engine.support.descriptor.ClassSource;
055import org.junit.platform.launcher.LauncherDiscoveryRequest;
056import org.junit.platform.launcher.TestExecutionListener;
057import org.junit.platform.launcher.TestIdentifier;
058import org.junit.platform.launcher.TestPlan;
059import org.junit.platform.launcher.core.LauncherDiscoveryRequestBuilder;
060import org.junit.platform.launcher.core.LauncherFactory;
061import org.junit.platform.launcher.listeners.LoggingListener;
062import org.junit.platform.launcher.listeners.SummaryGeneratingListener;
063
064/// A [Generator] for [TestResult]s using the JUnit platform. The runner
065/// assumes that it is configured as [Generator] for a test project. 
066/// The class path for running the tests is build as follows:
067/// 
068///  1. Request compilation classpath resources from the test project's
069///     dependencies with [Intent#Consume], [Intent#Expose],
070///     and [Intent#Supply]. This makes the resources available that
071///     are used for compiling test classes as well as the compiled
072///     test classes. 
073/// 
074///  2. If the project implements [MergedTestProject], get the
075///     [Project#parentProject()], request compilation class path
076///     resources from its dependencies with [Intent#Consume],
077///     [Intent#Expose], and [Intent#Supply] and add them to the
078///     class path. This makes the resources available that are used
079///     for compiling the classes under test as well as the classes
080///     under test. Note that this is partially redundant, because
081///     test projects most likely have a dependency with [Intent#Consume]
082///     on the project under test anyway in order to compile the test
083///     classes. This dependency does not, however, provide all resources
084///     that are required to test the project under test.  
085/// 
086/// The runner then requests all resources of type [ClassTree] from
087/// the test projects's [Generator]'s and passes them to JUnit's
088/// test class detector.
089/// 
090/// Libraries for compiling the tests and a test engine of your choice
091/// must be provided explicitly to the runner's project as dependencies,
092///  e.g. as:
093/// ```
094/// project.dependency(Consume, new MvnRepoLookup()
095///     .bom("org.junit:junit-bom:5.12.2")
096///     .resolve("org.junit.jupiter:junit-jupiter-api")
097///     .resolve(Scope.Runtime,
098///        "org.junit.jupiter:junit-jupiter-engine"));
099/// ```
100///
101/// In order to track the execution of the each test, you can enable
102/// level fine logging for this class. Level finer will also
103/// provide information about the class paths.
104/// 
105public class JUnitTestRunner extends AbstractGenerator {
106
107    private static final FluentLogger logger = FluentLogger.forEnclosingClass();
108    private boolean ignoreFailed;
109
110    /// Initializes a new test runner.
111    ///
112    /// @param project the project
113    ///
114    public JUnitTestRunner(Project project) {
115        super(project);
116    }
117
118    /// Ignore failed tests. If invoked, the test runner does not set the
119    /// faulty flag of the test results if a test has failed.
120    ///
121    /// @return the junit test runner
122    ///
123    public JUnitTestRunner ignoreFailed() {
124        this.ignoreFailed = true;
125        return this;
126    }
127
128    @Override
129    protected <T extends Resource> Stream<T>
130            doProvide(ResourceRequest<T> requested) {
131        if (!requested.accepts(new ResourceType<TestResult>() {})) {
132            return Stream.empty();
133        }
134
135        // Collect the classpath.
136        var cpResources = newResource(ClasspathType)
137            .addAll(project().resources(of(ClasspathElementType)
138                .using(Consume, Reveal, Expose, Supply)));
139        if (project() instanceof MergedTestProject) {
140            cpResources.addAll(project().parentProject().get()
141                .resources(of(ClasspathElement.class).using(Consume,
142                    Reveal, Expose, Supply)));
143        }
144        logger.atFiner().log("Testing in %s with classpath %s", project(),
145            lazy(() -> cpResources.stream().map(e -> e.toPath().toString())
146                .collect(Collectors.joining(File.pathSeparator))));
147
148        // Run the tests
149        ClassLoader oldLoader = Thread.currentThread().getContextClassLoader();
150        try (URLClassLoader testLoader = new URLClassLoader(
151            Stream.concat(project()
152                .resources(of(ClasspathElementType).using(Consume, Reveal)),
153                cpResources.stream()).map(ClasspathElement::toPath)
154                .map(Path::toUri).map(uri -> {
155                    try {
156                        return uri.toURL();
157                    } catch (MalformedURLException e) {
158                        throw new IllegalArgumentException(e);
159                    }
160                }).toArray(URL[]::new),
161            ClassLoader.getSystemClassLoader())) {
162            Thread.currentThread().setContextClassLoader(testLoader);
163
164            // Discover all tests from generator's output
165            var testClassTrees = project().providers(Consume, Reveal).filter(
166                p -> p instanceof Generator).resources(of(ClassTreeType))
167                .map(ClassTree::root).collect(Collectors.toSet());
168            LauncherDiscoveryRequest request
169                = LauncherDiscoveryRequestBuilder.request().selectors(
170                    DiscoverySelectors.selectClasspathRoots(testClassTrees))
171                    .build();
172
173            // Run the tests
174            var launcher = LauncherFactory.create();
175            var summaryListener = new SummaryGeneratingListener();
176            var testListener = new TestListener();
177            launcher.registerTestExecutionListeners(
178                LoggingListener.forJavaUtilLogging(), summaryListener,
179                testListener);
180            logger.atInfo().log("Running tests in project %s",
181                project().name());
182            launcher.execute(request);
183
184            // Evaluate results
185            var summary = summaryListener.getSummary();
186            var result = project().newResource(TestResultType,
187                this, buildName(testListener), summary.getTestsStartedCount(),
188                summary.getTestsFailedCount());
189            if (summary.getTestsFailedCount() > 0 && !ignoreFailed) {
190                result.setFaulty();
191            }
192            @SuppressWarnings("unchecked")
193            var asStream = Stream.of((T) result);
194            return asStream;
195        } catch (IOException e) {
196            logger.atWarning().withCause(e).log("Failed to close classloader");
197        } finally {
198            Thread.currentThread().setContextClassLoader(oldLoader);
199        }
200
201        // Return result
202        return Stream.empty();
203    }
204
205    @SuppressWarnings("PMD.AvoidLiteralsInIfCondition")
206    private String buildName(TestListener testListener) {
207        StringBuilder asList = new StringBuilder();
208        for (var testId : testListener.testIds()) {
209            if (!asList.isEmpty()) {
210                asList.append(", ");
211            }
212            if (asList.length() > 30) {
213                asList.append(" ...");
214                break;
215            }
216            asList.append(testId.getDisplayName());
217        }
218        return asList.toString();
219    }
220
221    /// A [TestExecutionListener] for JUnit.
222    ///
223    /// @see TestEvent
224    ///
225    @SuppressWarnings("PMD.TestClassWithoutTestCases")
226    private final class TestListener implements TestExecutionListener {
227
228        private final Set<TestIdentifier> tests = new HashSet<>();
229        private TestPlan testPlan;
230
231        /// Return the test classes.
232        ///
233        /// @return the sets the
234        ///
235        @SuppressWarnings("PMD.UnitTestShouldUseTestAnnotation")
236        public Set<TestIdentifier> testIds() {
237            return tests;
238        }
239
240        private String prettyTestName(TestIdentifier testId) {
241            return Stream.iterate(testId, Objects::nonNull,
242                id -> testPlan.getParent(id).orElse(null))
243                .toList().reversed().stream().skip(1)
244                .map(TestIdentifier::getDisplayName)
245                .collect(Collectors.joining(" > "));
246        }
247
248        @Override
249        @SuppressWarnings("PMD.UnitTestShouldUseTestAnnotation")
250        public void testPlanExecutionStarted(TestPlan testPlan) {
251            this.testPlan = testPlan;
252        }
253
254        @Override
255        @SuppressWarnings("PMD.UnitTestShouldUseTestAnnotation")
256        public void testPlanExecutionFinished(TestPlan testPlan) {
257            // Not tracked
258        }
259
260        @Override
261        public void dynamicTestRegistered(TestIdentifier testIdentifier) {
262            // Not tracked
263        }
264
265        @Override
266        public void executionSkipped(TestIdentifier testIdentifier,
267                String reason) {
268            // Not tracked
269        }
270
271        @Override
272        public void executionStarted(TestIdentifier testIdentifier) {
273            if (testIdentifier.getSource().isPresent()
274                && testIdentifier.getSource().get() instanceof ClassSource) {
275                tests.add(testIdentifier);
276            }
277        }
278
279        @Override
280        public void executionFinished(TestIdentifier testIdentifier,
281                TestExecutionResult testExecutionResult) {
282            if (testExecutionResult.getStatus() == Status.SUCCESSFUL) {
283                if (testIdentifier.getType() != Type.TEST) {
284                    return;
285                }
286                logger.atFine().log("Succeeded: %s",
287                    lazy(() -> prettyTestName(testIdentifier)));
288                return;
289            }
290            if (testExecutionResult.getThrowable().isEmpty()) {
291                logger.atWarning().log("Failed: %s",
292                    lazy(() -> prettyTestName(testIdentifier)));
293                return;
294            }
295            logger.atWarning()
296                .withCause(testExecutionResult.getThrowable().get())
297                .log("Failed: %s", lazy(() -> prettyTestName(testIdentifier)));
298        }
299
300        @Override
301        public void reportingEntryPublished(TestIdentifier testIdentifier,
302                ReportEntry entry) {
303            // Not tracked
304        }
305
306        @Override
307        public void fileEntryPublished(TestIdentifier testIdentifier,
308                FileEntry file) {
309            // Not tracked
310        }
311    }
312}