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 org.jdrupes.builder.api.Resources;
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    private Object syncObject;
110
111    /// Initializes a new test runner.
112    ///
113    /// @param project the project
114    ///
115    public JUnitTestRunner(Project project) {
116        super(project);
117    }
118
119    /// Ignore failed tests. If invoked, the test runner does not set the
120    /// faulty flag of the test results if a test has failed.
121    ///
122    /// @return the junit test runner
123    ///
124    public JUnitTestRunner ignoreFailed() {
125        this.ignoreFailed = true;
126        return this;
127    }
128
129    /// By default, [JUnitTestRunner]s run independently of each other.
130    /// Because they run in the VM, this can cause concurrency issues if
131    /// components in different test projects share static resources.
132    /// 
133    /// By invoking this method, [JUnitTestRunner]s with the same
134    /// [syncObject] are synchronized, i.e. run in sequence.
135    ///
136    /// @param syncObject the sync object
137    /// @return the j unit test runner
138    ///
139    public JUnitTestRunner syncOn(Object syncObject) {
140        this.syncObject = syncObject;
141        return this;
142    }
143
144    @Override
145    @SuppressWarnings({ "PMD.AvoidSynchronizedStatement", "PMD.NcssCount" })
146    protected <T extends Resource> Stream<T>
147            doProvide(ResourceRequest<T> requested) {
148        if (!requested.accepts(new ResourceType<TestResult>() {})) {
149            return Stream.empty();
150        }
151
152        // Collect the classpath.
153        var cpResources = Resources.of(ClasspathType)
154            .addAll(project().resources(of(ClasspathElementType)
155                .using(Consume, Reveal, Expose, Supply)));
156        if (project() instanceof MergedTestProject) {
157            cpResources.addAll(project().parentProject().get()
158                .resources(of(ClasspathElement.class).using(Consume,
159                    Reveal, Expose, Supply)));
160        }
161        logger.atFiner().log("Testing in %s with classpath %s", project(),
162            lazy(() -> cpResources.stream().map(e -> e.toPath().toString())
163                .collect(Collectors.joining(File.pathSeparator))));
164
165        // Run the tests
166        ClassLoader oldLoader = Thread.currentThread().getContextClassLoader();
167        try (URLClassLoader testLoader = new URLClassLoader(
168            Stream.concat(project()
169                .resources(of(ClasspathElementType).using(Consume, Reveal)),
170                cpResources.stream()).map(ClasspathElement::toPath)
171                .map(Path::toUri).map(uri -> {
172                    try {
173                        return uri.toURL();
174                    } catch (MalformedURLException e) {
175                        throw new IllegalArgumentException(e);
176                    }
177                }).toArray(URL[]::new),
178            ClassLoader.getSystemClassLoader())) {
179            Thread.currentThread().setContextClassLoader(testLoader);
180
181            // Discover all tests from generator's output
182            var testClassTrees = project().providers(Consume, Reveal).filter(
183                p -> p instanceof Generator).resources(of(ClassTreeType))
184                .map(ClassTree::root).collect(Collectors.toSet());
185            LauncherDiscoveryRequest request
186                = LauncherDiscoveryRequestBuilder.request().selectors(
187                    DiscoverySelectors.selectClasspathRoots(testClassTrees))
188                    .build();
189
190            // Run the tests
191            var launcher = LauncherFactory.create();
192            var summaryListener = new SummaryGeneratingListener();
193            var testListener = new TestListener();
194            launcher.registerTestExecutionListeners(
195                LoggingListener.forJavaUtilLogging(), summaryListener,
196                testListener);
197            logger.atInfo().log("Running tests in project %s",
198                project().name());
199            if (syncObject != null) {
200                synchronized (syncObject) {
201                    launcher.execute(request);
202                }
203            } else {
204                launcher.execute(request);
205            }
206
207            // Evaluate results
208            var summary = summaryListener.getSummary();
209            var result = TestResult.of(project(), this,
210                buildName(testListener), summary.getTestsStartedCount(),
211                summary.getTestsFailedCount());
212            if (summary.getTestsFailedCount() > 0 && !ignoreFailed) {
213                result.setFaulty();
214            }
215            @SuppressWarnings("unchecked")
216            var asStream = Stream.of((T) result);
217            return asStream;
218        } catch (IOException e) {
219            logger.atWarning().withCause(e).log("Failed to close classloader");
220        } finally {
221            Thread.currentThread().setContextClassLoader(oldLoader);
222        }
223
224        // Return result
225        return Stream.empty();
226    }
227
228    @SuppressWarnings("PMD.AvoidLiteralsInIfCondition")
229    private String buildName(TestListener testListener) {
230        StringBuilder asList = new StringBuilder();
231        for (var testId : testListener.testIds()) {
232            if (!asList.isEmpty()) {
233                asList.append(", ");
234            }
235            if (asList.length() > 30) {
236                asList.append(" ...");
237                break;
238            }
239            asList.append(testId.getDisplayName());
240        }
241        return asList.toString();
242    }
243
244    private void printExecutionResult(String testName,
245            TestExecutionResult result) {
246        context().error().format("Failed: %s\n", testName);
247        if (result.getThrowable().isEmpty()) {
248            return;
249        }
250
251        // Find initial exception
252        Throwable thrown = result.getThrowable().get();
253        while (thrown.getCause() != null) {
254            thrown = thrown.getCause();
255        }
256        thrown.printStackTrace(context().error());
257    }
258
259    /// A [TestExecutionListener] for JUnit.
260    ///
261    /// @see TestEvent
262    ///
263    @SuppressWarnings("PMD.TestClassWithoutTestCases")
264    private final class TestListener implements TestExecutionListener {
265
266        private final Set<TestIdentifier> tests = new HashSet<>();
267        private TestPlan testPlan;
268
269        /// Return the test classes.
270        ///
271        /// @return the sets the
272        ///
273        private Set<TestIdentifier> testIds() {
274            return tests;
275        }
276
277        private String prettyTestName(TestIdentifier testId) {
278            return Stream.iterate(testId, Objects::nonNull,
279                id -> testPlan.getParent(id).orElse(null))
280                .toList().reversed().stream().skip(1)
281                .map(TestIdentifier::getDisplayName)
282                .collect(Collectors.joining(" > "));
283        }
284
285        @Override
286        @SuppressWarnings("PMD.UnitTestShouldUseTestAnnotation")
287        public void testPlanExecutionStarted(TestPlan testPlan) {
288            this.testPlan = testPlan;
289        }
290
291        @Override
292        @SuppressWarnings("PMD.UnitTestShouldUseTestAnnotation")
293        public void testPlanExecutionFinished(TestPlan testPlan) {
294            // Not tracked
295        }
296
297        @Override
298        public void dynamicTestRegistered(TestIdentifier testIdentifier) {
299            // Not tracked
300        }
301
302        @Override
303        public void executionSkipped(TestIdentifier testIdentifier,
304                String reason) {
305            // Not tracked
306        }
307
308        @Override
309        public void executionStarted(TestIdentifier testIdentifier) {
310            if (testIdentifier.getSource().isPresent()
311                && testIdentifier.getSource().get() instanceof ClassSource) {
312                tests.add(testIdentifier);
313            }
314            context().statusLine().update(JUnitTestRunner.this
315                + " running: " + prettyTestName(testIdentifier));
316        }
317
318        @Override
319        public void executionFinished(TestIdentifier testIdentifier,
320                TestExecutionResult testExecutionResult) {
321            if (testExecutionResult.getStatus() == Status.SUCCESSFUL) {
322                if (testIdentifier.getType() != Type.TEST) {
323                    return;
324                }
325                logger.atFine().log("Succeeded: %s",
326                    lazy(() -> prettyTestName(testIdentifier)));
327                return;
328            }
329            if (testExecutionResult.getThrowable().isEmpty()) {
330                logger.atWarning().log("Failed: %s",
331                    lazy(() -> prettyTestName(testIdentifier)));
332                printExecutionResult(prettyTestName(testIdentifier),
333                    testExecutionResult);
334                return;
335            }
336            logger.atWarning()
337                .withCause(testExecutionResult.getThrowable().get())
338                .log("Failed: %s", lazy(() -> prettyTestName(testIdentifier)));
339            printExecutionResult(prettyTestName(testIdentifier),
340                testExecutionResult);
341        }
342
343        @Override
344        public void reportingEntryPublished(TestIdentifier testIdentifier,
345                ReportEntry entry) {
346            // Not tracked
347        }
348
349        @Override
350        public void fileEntryPublished(TestIdentifier testIdentifier,
351                FileEntry file) {
352            // Not tracked
353        }
354    }
355}