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.Collection;
030import java.util.Collections;
031import java.util.HashSet;
032import java.util.List;
033import java.util.Objects;
034import java.util.Set;
035import java.util.stream.Collectors;
036import java.util.stream.Stream;
037import org.jdrupes.builder.api.Generator;
038import org.jdrupes.builder.api.Intent;
039import static org.jdrupes.builder.api.Intent.*;
040import org.jdrupes.builder.api.MergedTestProject;
041import org.jdrupes.builder.api.Project;
042import org.jdrupes.builder.api.Resource;
043import org.jdrupes.builder.api.ResourceRequest;
044import org.jdrupes.builder.api.ResourceType;
045import org.jdrupes.builder.api.Resources;
046import org.jdrupes.builder.api.TestResult;
047import org.jdrupes.builder.core.AbstractGenerator;
048import org.jdrupes.builder.java.ClassTree;
049import org.jdrupes.builder.java.ClasspathElement;
050import static org.jdrupes.builder.java.JavaTypes.*;
051import org.junit.platform.engine.TestDescriptor.Type;
052import org.junit.platform.engine.TestExecutionResult;
053import org.junit.platform.engine.TestExecutionResult.Status;
054import org.junit.platform.engine.discovery.DiscoverySelectors;
055import org.junit.platform.engine.reporting.FileEntry;
056import org.junit.platform.engine.reporting.ReportEntry;
057import org.junit.platform.engine.support.descriptor.ClassSource;
058import org.junit.platform.launcher.LauncherDiscoveryRequest;
059import org.junit.platform.launcher.TestExecutionListener;
060import org.junit.platform.launcher.TestIdentifier;
061import org.junit.platform.launcher.TestPlan;
062import org.junit.platform.launcher.core.LauncherDiscoveryRequestBuilder;
063import org.junit.platform.launcher.core.LauncherFactory;
064import org.junit.platform.launcher.listeners.LoggingListener;
065import org.junit.platform.launcher.listeners.SummaryGeneratingListener;
066
067/// A [Generator] for [TestResult]s using the JUnit platform. The runner
068/// assumes that it is configured as [Generator] for a test project. 
069/// The class path for running the tests is build as follows:
070/// 
071///  1. Request compilation classpath resources from the test project's
072///     dependencies with [Intent#Consume], [Intent#Expose],
073///     and [Intent#Supply]. This makes the resources available that
074///     are used for compiling test classes as well as the compiled
075///     test classes. 
076/// 
077///  2. If the project implements [MergedTestProject], get the
078///     [Project#parentProject()], request compilation class path
079///     resources from its dependencies with [Intent#Consume],
080///     [Intent#Expose], and [Intent#Supply] and add them to the
081///     class path. This makes the resources available that are used
082///     for compiling the classes under test as well as the classes
083///     under test. Note that this is partially redundant, because
084///     test projects most likely have a dependency with [Intent#Consume]
085///     on the project under test anyway in order to compile the test
086///     classes. This dependency does not, however, provide all resources
087///     that are required to test the project under test.  
088/// 
089/// The runner then requests all resources of type [ClassTree] from
090/// the test projects's [Generator]'s and passes them to JUnit's
091/// test class detector.
092/// 
093/// Libraries for compiling the tests and a test engine of your choice
094/// must be provided explicitly to the runner's project as dependencies,
095///  e.g. as:
096/// ```
097/// project.dependency(Consume, new MvnRepoLookup()
098///     .bom("org.junit:junit-bom:5.12.2")
099///     .resolve("org.junit.jupiter:junit-jupiter-api")
100///     .resolve(Scope.Runtime,
101///        "org.junit.jupiter:junit-jupiter-engine"));
102/// ```
103///
104/// In order to track the execution of the each test, you can enable
105/// level fine logging for this class. Level finer will also
106/// provide information about the class paths.
107/// 
108public class JUnitTestRunner extends AbstractGenerator {
109
110    private static final FluentLogger logger = FluentLogger.forEnclosingClass();
111    private boolean ignoreFailed;
112    private Object syncObject;
113
114    /// Initializes a new test runner.
115    ///
116    /// @param project the project
117    ///
118    public JUnitTestRunner(Project project) {
119        super(project);
120    }
121
122    /// Ignore failed tests. If invoked, the test runner does not set the
123    /// faulty flag of the test results if a test has failed.
124    ///
125    /// @return the junit test runner
126    ///
127    public JUnitTestRunner ignoreFailed() {
128        this.ignoreFailed = true;
129        return this;
130    }
131
132    /// By default, [JUnitTestRunner]s run independently of each other.
133    /// Because they run in the VM, this can cause concurrency issues if
134    /// components in different test projects share static resources.
135    /// 
136    /// By invoking this method, [JUnitTestRunner]s with the same
137    /// [syncObject] are synchronized, i.e. run in sequence.
138    ///
139    /// @param syncObject the sync object
140    /// @return the j unit test runner
141    ///
142    public JUnitTestRunner syncOn(Object syncObject) {
143        this.syncObject = syncObject;
144        return this;
145    }
146
147    @Override
148    @SuppressWarnings({ "PMD.AvoidSynchronizedStatement", "PMD.NcssCount" })
149    protected <T extends Resource> Collection<T>
150            doProvide(ResourceRequest<T> requested) {
151        if (!requested.accepts(new ResourceType<TestResult>() {})) {
152            return Collections.emptyList();
153        }
154
155        // Collect the classpath.
156        var cpResources = Resources.of(ClasspathType)
157            .addAll(project().resources(of(ClasspathElementType)
158                .using(Consume, Reveal, Expose, Supply)));
159        if (project() instanceof MergedTestProject) {
160            cpResources.addAll(project().parentProject().get()
161                .resources(of(ClasspathElementType).using(Consume,
162                    Reveal, Expose, Supply)));
163        }
164        logger.atFiner().log("Testing in %s with classpath %s", project(),
165            lazy(() -> cpResources.stream().map(e -> e.toPath().toString())
166                .collect(Collectors.joining(File.pathSeparator))));
167
168        // Run the tests
169        ClassLoader oldLoader = Thread.currentThread().getContextClassLoader();
170        try (URLClassLoader testLoader = new URLClassLoader(
171            Stream.concat(project()
172                .resources(of(ClasspathElementType).using(Consume, Reveal)),
173                cpResources.stream()).map(ClasspathElement::toPath)
174                .map(Path::toUri).map(uri -> {
175                    try {
176                        return uri.toURL();
177                    } catch (MalformedURLException e) {
178                        throw new IllegalArgumentException(e);
179                    }
180                }).toArray(URL[]::new),
181            ClassLoader.getSystemClassLoader())) {
182            Thread.currentThread().setContextClassLoader(testLoader);
183
184            // Discover all tests from generator's output
185            var testClassTrees = project().providers(Consume, Reveal).filter(
186                p -> p instanceof Generator).resources(of(ClassTreeType))
187                .map(ClassTree::root).collect(Collectors.toSet());
188            LauncherDiscoveryRequest request
189                = LauncherDiscoveryRequestBuilder.request().selectors(
190                    DiscoverySelectors.selectClasspathRoots(testClassTrees))
191                    .build();
192
193            // Run the tests
194            var launcher = LauncherFactory.create();
195            var summaryListener = new SummaryGeneratingListener();
196            var testListener = new TestListener();
197            launcher.registerTestExecutionListeners(
198                LoggingListener.forJavaUtilLogging(), summaryListener,
199                testListener);
200            logger.atInfo().log("Running tests in project %s",
201                project().name());
202            if (syncObject != null) {
203                synchronized (syncObject) {
204                    launcher.execute(request);
205                }
206            } else {
207                launcher.execute(request);
208            }
209
210            // Evaluate results
211            var summary = summaryListener.getSummary();
212            var result = TestResult.of(project(), this,
213                buildName(testListener), summary.getTestsStartedCount(),
214                summary.getTestsFailedCount());
215            if (summary.getTestsFailedCount() > 0 && !ignoreFailed) {
216                result.setFaulty();
217            }
218            @SuppressWarnings("unchecked")
219            var asList = List.of((T) result);
220            return asList;
221        } catch (IOException e) {
222            logger.atWarning().withCause(e).log("Failed to close classloader");
223        } finally {
224            Thread.currentThread().setContextClassLoader(oldLoader);
225        }
226
227        // Return result
228        return Collections.emptyList();
229    }
230
231    @SuppressWarnings("PMD.AvoidLiteralsInIfCondition")
232    private String buildName(TestListener testListener) {
233        StringBuilder asList = new StringBuilder();
234        for (var testId : testListener.testIds()) {
235            if (!asList.isEmpty()) {
236                asList.append(", ");
237            }
238            if (asList.length() > 30) {
239                asList.append(" ...");
240                break;
241            }
242            asList.append(testId.getDisplayName());
243        }
244        return asList.toString();
245    }
246
247    private void printExecutionResult(String testName,
248            TestExecutionResult result) {
249        context().error().format("Failed: %s\n", testName);
250        if (result.getThrowable().isEmpty()) {
251            return;
252        }
253
254        // Find INITIAL exception
255        Throwable thrown = result.getThrowable().get();
256        while (thrown.getCause() != null) {
257            thrown = thrown.getCause();
258        }
259        thrown.printStackTrace(context().error());
260    }
261
262    /// A [TestExecutionListener] for JUnit.
263    ///
264    /// @see TestEvent
265    ///
266    @SuppressWarnings("PMD.TestClassWithoutTestCases")
267    private final class TestListener implements TestExecutionListener {
268
269        private final Set<TestIdentifier> tests = new HashSet<>();
270        private TestPlan testPlan;
271
272        /// Return the test classes.
273        ///
274        /// @return the sets the
275        ///
276        private Set<TestIdentifier> testIds() {
277            return tests;
278        }
279
280        private String prettyTestName(TestIdentifier testId) {
281            return Stream.iterate(testId, Objects::nonNull,
282                id -> testPlan.getParent(id).orElse(null))
283                .toList().reversed().stream().skip(1)
284                .map(TestIdentifier::getDisplayName)
285                .collect(Collectors.joining(" > "));
286        }
287
288        @Override
289        @SuppressWarnings("PMD.UnitTestShouldUseTestAnnotation")
290        public void testPlanExecutionStarted(TestPlan testPlan) {
291            this.testPlan = testPlan;
292        }
293
294        @Override
295        @SuppressWarnings("PMD.UnitTestShouldUseTestAnnotation")
296        public void testPlanExecutionFinished(TestPlan testPlan) {
297            // Not tracked
298        }
299
300        @Override
301        public void dynamicTestRegistered(TestIdentifier testIdentifier) {
302            // Not tracked
303        }
304
305        @Override
306        public void executionSkipped(TestIdentifier testIdentifier,
307                String reason) {
308            // Not tracked
309        }
310
311        @Override
312        public void executionStarted(TestIdentifier testIdentifier) {
313            if (testIdentifier.getSource().isPresent()
314                && testIdentifier.getSource().get() instanceof ClassSource) {
315                tests.add(testIdentifier);
316            }
317            context().statusLine().update(JUnitTestRunner.this
318                + " running: " + prettyTestName(testIdentifier));
319        }
320
321        @Override
322        public void executionFinished(TestIdentifier testIdentifier,
323                TestExecutionResult testExecutionResult) {
324            if (testExecutionResult.getStatus() == Status.SUCCESSFUL) {
325                if (testIdentifier.getType() != Type.TEST) {
326                    return;
327                }
328                logger.atFine().log("Succeeded: %s",
329                    lazy(() -> prettyTestName(testIdentifier)));
330                return;
331            }
332            if (testExecutionResult.getThrowable().isEmpty()) {
333                logger.atWarning().log("Failed: %s",
334                    lazy(() -> prettyTestName(testIdentifier)));
335                printExecutionResult(prettyTestName(testIdentifier),
336                    testExecutionResult);
337                return;
338            }
339            logger.atWarning()
340                .withCause(testExecutionResult.getThrowable().get())
341                .log("Failed: %s", lazy(() -> prettyTestName(testIdentifier)));
342            printExecutionResult(prettyTestName(testIdentifier),
343                testExecutionResult);
344        }
345
346        @Override
347        public void reportingEntryPublished(TestIdentifier testIdentifier,
348                ReportEntry entry) {
349            // Not tracked
350        }
351
352        @Override
353        public void fileEntryPublished(TestIdentifier testIdentifier,
354                FileEntry file) {
355            // Not tracked
356        }
357    }
358}