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 static 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.CodeContribution;
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        // Check if provided and evaluate for most special type
152        if (!requested.accepts(TestResultType)) {
153            return Collections.emptyList();
154        }
155        if (!requested.isFor(TestResultType)) {
156            @SuppressWarnings("unchecked")
157            var result = (Collection<T>) resources(of(TestResultType)).toList();
158            return result;
159        }
160
161        // Collect the classpath.
162        var cpResources = Resources.of(CodeContributionsType)
163            .addAll(project().resources(of(CodeContributionType)
164                .using(Consume, Reveal, Expose, Supply)));
165        if (project() instanceof MergedTestProject) {
166            cpResources.addAll(project().parentProject().get()
167                .resources(of(CodeContributionType).using(Consume,
168                    Reveal, Expose, Supply)));
169        }
170        logger.atFiner().log("Testing in %s with classpath %s", project(),
171            lazy(() -> cpResources.stream().map(e -> e.toPath().toString())
172                .collect(Collectors.joining(File.pathSeparator))));
173
174        // Run the tests
175        ClassLoader oldLoader = Thread.currentThread().getContextClassLoader();
176        try (URLClassLoader testLoader = new URLClassLoader(
177            Stream.concat(project()
178                .resources(of(CodeContributionType).using(Consume, Reveal)),
179                cpResources.stream()).map(CodeContribution::toPath)
180                .map(Path::toUri).map(uri -> {
181                    try {
182                        return uri.toURL();
183                    } catch (MalformedURLException e) {
184                        throw new IllegalArgumentException(e);
185                    }
186                }).toArray(URL[]::new),
187            ClassLoader.getSystemClassLoader())) {
188            Thread.currentThread().setContextClassLoader(testLoader);
189
190            // Discover all tests from generator's output
191            var testClassTrees = project().providers(Consume, Reveal).filter(
192                p -> p instanceof Generator).resources(of(ClassTreeType))
193                .map(ClassTree::root).collect(Collectors.toSet());
194            LauncherDiscoveryRequest request
195                = LauncherDiscoveryRequestBuilder.request().selectors(
196                    DiscoverySelectors.selectClasspathRoots(testClassTrees))
197                    .build();
198
199            // Run the tests
200            var launcher = LauncherFactory.create();
201            var summaryListener = new SummaryGeneratingListener();
202            var testListener = new TestListener();
203            launcher.registerTestExecutionListeners(
204                LoggingListener.forJavaUtilLogging(), summaryListener,
205                testListener);
206            logger.atInfo().log("Running tests in project %s",
207                project().name());
208            if (syncObject != null) {
209                synchronized (syncObject) {
210                    launcher.execute(request);
211                }
212            } else {
213                launcher.execute(request);
214            }
215
216            // Evaluate results
217            var summary = summaryListener.getSummary();
218            var result = TestResult.of(project(), this,
219                buildName(testListener), summary.getTestsStartedCount(),
220                summary.getTestsFailedCount());
221            if (summary.getTestsFailedCount() > 0 && !ignoreFailed) {
222                result.setFaulty();
223            }
224            @SuppressWarnings("unchecked")
225            var asList = List.of((T) result);
226            return asList;
227        } catch (IOException e) {
228            logger.atWarning().withCause(e).log("Failed to close classloader");
229        } finally {
230            Thread.currentThread().setContextClassLoader(oldLoader);
231        }
232
233        // Return result
234        return Collections.emptyList();
235    }
236
237    @SuppressWarnings("PMD.AvoidLiteralsInIfCondition")
238    private String buildName(TestListener testListener) {
239        StringBuilder asList = new StringBuilder();
240        for (var testId : testListener.testIds()) {
241            if (!asList.isEmpty()) {
242                asList.append(", ");
243            }
244            if (asList.length() > 30) {
245                asList.append(" ...");
246                break;
247            }
248            asList.append(testId.getDisplayName());
249        }
250        return asList.toString();
251    }
252
253    private void printExecutionResult(String testName,
254            TestExecutionResult result) {
255        context().error().format("Failed: %s\n", testName);
256        if (result.getThrowable().isEmpty()) {
257            return;
258        }
259
260        // Find INITIAL exception
261        Throwable thrown = result.getThrowable().get();
262        while (thrown.getCause() != null) {
263            thrown = thrown.getCause();
264        }
265        thrown.printStackTrace(context().error());
266    }
267
268    /// A [TestExecutionListener] for JUnit.
269    ///
270    /// @see TestEvent
271    ///
272    @SuppressWarnings("PMD.TestClassWithoutTestCases")
273    private final class TestListener implements TestExecutionListener {
274
275        private final Set<TestIdentifier> tests = new HashSet<>();
276        private TestPlan testPlan;
277
278        /// Return the test classes.
279        ///
280        /// @return the sets the
281        ///
282        private Set<TestIdentifier> testIds() {
283            return tests;
284        }
285
286        private String prettyTestName(TestIdentifier testId) {
287            return Stream.iterate(testId, Objects::nonNull,
288                id -> testPlan.getParent(id).orElse(null))
289                .toList().reversed().stream().skip(1)
290                .map(TestIdentifier::getDisplayName)
291                .collect(Collectors.joining(" > "));
292        }
293
294        @Override
295        @SuppressWarnings("PMD.UnitTestShouldUseTestAnnotation")
296        public void testPlanExecutionStarted(TestPlan testPlan) {
297            this.testPlan = testPlan;
298        }
299
300        @Override
301        @SuppressWarnings("PMD.UnitTestShouldUseTestAnnotation")
302        public void testPlanExecutionFinished(TestPlan testPlan) {
303            // Not tracked
304        }
305
306        @Override
307        public void dynamicTestRegistered(TestIdentifier testIdentifier) {
308            // Not tracked
309        }
310
311        @Override
312        public void executionSkipped(TestIdentifier testIdentifier,
313                String reason) {
314            // Not tracked
315        }
316
317        @Override
318        public void executionStarted(TestIdentifier testIdentifier) {
319            if (testIdentifier.getSource().isPresent()
320                && testIdentifier.getSource().get() instanceof ClassSource) {
321                tests.add(testIdentifier);
322            }
323            context().statusLine().update(JUnitTestRunner.this
324                + " running: " + prettyTestName(testIdentifier));
325        }
326
327        @Override
328        public void executionFinished(TestIdentifier testIdentifier,
329                TestExecutionResult testExecutionResult) {
330            if (testExecutionResult.getStatus() == Status.SUCCESSFUL) {
331                if (testIdentifier.getType() != Type.TEST) {
332                    return;
333                }
334                logger.atFine().log("Succeeded: %s",
335                    lazy(() -> prettyTestName(testIdentifier)));
336                return;
337            }
338            if (testExecutionResult.getThrowable().isEmpty()) {
339                logger.atWarning().log("Failed: %s",
340                    lazy(() -> prettyTestName(testIdentifier)));
341                printExecutionResult(prettyTestName(testIdentifier),
342                    testExecutionResult);
343                return;
344            }
345            logger.atWarning()
346                .withCause(testExecutionResult.getThrowable().get())
347                .log("Failed: %s", lazy(() -> prettyTestName(testIdentifier)));
348            printExecutionResult(prettyTestName(testIdentifier),
349                testExecutionResult);
350        }
351
352        @Override
353        public void reportingEntryPublished(TestIdentifier testIdentifier,
354                ReportEntry entry) {
355            // Not tracked
356        }
357
358        @Override
359        public void fileEntryPublished(TestIdentifier testIdentifier,
360                FileEntry file) {
361            // Not tracked
362        }
363    }
364}