View Javadoc
1   /*
2    * Licensed to the Apache Software Foundation (ASF) under one
3    * or more contributor license agreements.  See the NOTICE file
4    * distributed with this work for additional information
5    * regarding copyright ownership.  The ASF licenses this file
6    * to you under the Apache License, Version 2.0 (the
7    * "License"); you may not use this file except in compliance
8    * with the License.  You may obtain a copy of the License at
9    *
10   *   http://www.apache.org/licenses/LICENSE-2.0
11   *
12   * Unless required by applicable law or agreed to in writing,
13   * software distributed under the License is distributed on an
14   * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15   * KIND, either express or implied.  See the License for the
16   * specific language governing permissions and limitations
17   * under the License.
18   */
19  package org.apache.maven.plugin.testing.junit5;
20  
21  import java.io.BufferedReader;
22  import java.io.File;
23  import java.io.InputStream;
24  import java.io.Reader;
25  import java.io.StringReader;
26  import java.lang.reflect.AccessibleObject;
27  import java.lang.reflect.Field;
28  import java.net.URL;
29  import java.nio.file.Path;
30  import java.nio.file.Paths;
31  import java.util.ArrayList;
32  import java.util.Arrays;
33  import java.util.Collection;
34  import java.util.Collections;
35  import java.util.HashMap;
36  import java.util.HashSet;
37  import java.util.List;
38  import java.util.Map;
39  import java.util.Objects;
40  import java.util.Optional;
41  import java.util.Properties;
42  import java.util.Set;
43  import java.util.stream.Collectors;
44  import java.util.stream.Stream;
45  
46  import com.google.inject.internal.ProviderMethodsModule;
47  import org.apache.maven.api.plugin.testing.MojoTest;
48  import org.apache.maven.execution.MavenSession;
49  import org.apache.maven.lifecycle.internal.MojoDescriptorCreator;
50  import org.apache.maven.plugin.Mojo;
51  import org.apache.maven.plugin.MojoExecution;
52  import org.apache.maven.plugin.PluginParameterExpressionEvaluator;
53  import org.apache.maven.plugin.descriptor.MojoDescriptor;
54  import org.apache.maven.plugin.descriptor.Parameter;
55  import org.apache.maven.plugin.descriptor.PluginDescriptor;
56  import org.apache.maven.plugin.descriptor.PluginDescriptorBuilder;
57  import org.apache.maven.plugin.logging.Log;
58  import org.apache.maven.plugin.testing.ConfigurationException;
59  import org.apache.maven.plugin.testing.MojoLogWrapper;
60  import org.apache.maven.project.MavenProject;
61  import org.codehaus.plexus.DefaultPlexusContainer;
62  import org.codehaus.plexus.PlexusContainer;
63  import org.codehaus.plexus.component.configurator.BasicComponentConfigurator;
64  import org.codehaus.plexus.component.configurator.ComponentConfigurator;
65  import org.codehaus.plexus.component.configurator.expression.ExpressionEvaluationException;
66  import org.codehaus.plexus.component.configurator.expression.ExpressionEvaluator;
67  import org.codehaus.plexus.component.configurator.expression.TypeAwareExpressionEvaluator;
68  import org.codehaus.plexus.component.repository.ComponentDescriptor;
69  import org.codehaus.plexus.component.repository.exception.ComponentLookupException;
70  import org.codehaus.plexus.configuration.xml.XmlPlexusConfiguration;
71  import org.codehaus.plexus.testing.PlexusExtension;
72  import org.codehaus.plexus.util.InterpolationFilterReader;
73  import org.codehaus.plexus.util.ReaderFactory;
74  import org.codehaus.plexus.util.ReflectionUtils;
75  import org.codehaus.plexus.util.xml.XmlStreamReader;
76  import org.codehaus.plexus.util.xml.Xpp3Dom;
77  import org.codehaus.plexus.util.xml.Xpp3DomBuilder;
78  import org.junit.jupiter.api.extension.ExtensionContext;
79  import org.junit.jupiter.api.extension.ParameterContext;
80  import org.junit.jupiter.api.extension.ParameterResolutionException;
81  import org.junit.jupiter.api.extension.ParameterResolver;
82  import org.mockito.Mockito;
83  import org.slf4j.LoggerFactory;
84  
85  /**
86   * JUnit extension to help testing Mojos. The extension should be automatically registered
87   * by adding the {@link org.apache.maven.api.plugin.testing.MojoTest} annotation on the test class.
88   *
89   * @see MojoTest
90   * @see org.apache.maven.api.plugin.testing.InjectMojo
91   * @see org.apache.maven.api.plugin.testing.MojoParameter
92   */
93  public class MojoExtension extends PlexusExtension implements ParameterResolver {
94  
95      @Override
96      public boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext)
97              throws ParameterResolutionException {
98          return parameterContext.isAnnotated(InjectMojo.class)
99                  || parameterContext.getDeclaringExecutable().isAnnotationPresent(InjectMojo.class);
100     }
101 
102     @Override
103     public Object resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext)
104             throws ParameterResolutionException {
105         try {
106             InjectMojo injectMojo = parameterContext
107                     .findAnnotation(InjectMojo.class)
108                     .orElseGet(() -> parameterContext.getDeclaringExecutable().getAnnotation(InjectMojo.class));
109 
110             Set<MojoParameter> mojoParameters =
111                     new HashSet<>(parameterContext.findRepeatableAnnotations(MojoParameter.class));
112 
113             Optional.ofNullable(parameterContext.getDeclaringExecutable().getAnnotation(MojoParameter.class))
114                     .ifPresent(mojoParameters::add);
115 
116             Optional.ofNullable(parameterContext.getDeclaringExecutable().getAnnotation(MojoParameters.class))
117                     .map(MojoParameters::value)
118                     .map(Arrays::asList)
119                     .ifPresent(mojoParameters::addAll);
120 
121             Class<?> holder = parameterContext.getTarget().get().getClass();
122             PluginDescriptor descriptor = extensionContext
123                     .getStore(ExtensionContext.Namespace.GLOBAL)
124                     .get(PluginDescriptor.class, PluginDescriptor.class);
125             return lookupMojo(holder, injectMojo, mojoParameters, descriptor);
126         } catch (Exception e) {
127             throw new ParameterResolutionException("Unable to resolve parameter", e);
128         }
129     }
130 
131     @Override
132     public void beforeEach(ExtensionContext context) throws Exception {
133         // TODO provide protected setters in PlexusExtension
134         Field field = PlexusExtension.class.getDeclaredField("basedir");
135         field.setAccessible(true);
136         field.set(null, getBasedir());
137         field = PlexusExtension.class.getDeclaredField("context");
138         field.setAccessible(true);
139         field.set(this, context);
140 
141         getContainer().addComponent(getContainer(), PlexusContainer.class.getName());
142 
143         ((DefaultPlexusContainer) getContainer()).addPlexusInjector(Collections.emptyList(), binder -> {
144             binder.install(ProviderMethodsModule.forObject(context.getRequiredTestInstance()));
145             binder.requestInjection(context.getRequiredTestInstance());
146             binder.bind(Log.class).toInstance(new MojoLogWrapper(LoggerFactory.getLogger("anonymous")));
147             binder.bind(MavenSession.class).toInstance(mockMavenSession());
148             binder.bind(MojoExecution.class).toInstance(mockMojoExecution());
149         });
150 
151         Map<Object, Object> map = getContainer().getContext().getContextData();
152 
153         ClassLoader classLoader = context.getRequiredTestClass().getClassLoader();
154         try (InputStream is = Objects.requireNonNull(
155                         classLoader.getResourceAsStream(getPluginDescriptorLocation()),
156                         "Unable to find plugin descriptor: " + getPluginDescriptorLocation());
157                 Reader reader = new BufferedReader(new XmlStreamReader(is));
158                 InterpolationFilterReader interpolationReader = new InterpolationFilterReader(reader, map, "${", "}")) {
159 
160             PluginDescriptor pluginDescriptor = new PluginDescriptorBuilder().build(interpolationReader);
161 
162             context.getStore(ExtensionContext.Namespace.GLOBAL).put(PluginDescriptor.class, pluginDescriptor);
163 
164             for (ComponentDescriptor<?> desc : pluginDescriptor.getComponents()) {
165                 getContainer().addComponentDescriptor(desc);
166             }
167         }
168     }
169 
170     /**
171      * Default MojoExecution mock
172      *
173      * @return a MojoExecution mock
174      */
175     private MojoExecution mockMojoExecution() {
176         return Mockito.mock(MojoExecution.class);
177     }
178 
179     /**
180      * Default MavenSession mock
181      *
182      * @return a MavenSession mock
183      */
184     private MavenSession mockMavenSession() {
185         MavenSession session = Mockito.mock(MavenSession.class);
186         Mockito.when(session.getUserProperties()).thenReturn(new Properties());
187         Mockito.when(session.getSystemProperties()).thenReturn(new Properties());
188         return session;
189     }
190 
191     protected String getPluginDescriptorLocation() {
192         return "META-INF/maven/plugin.xml";
193     }
194 
195     private Mojo lookupMojo(
196             Class<?> holder,
197             InjectMojo injectMojo,
198             Collection<MojoParameter> mojoParameters,
199             PluginDescriptor descriptor)
200             throws Exception {
201         String goal = injectMojo.goal();
202         String pom = injectMojo.pom();
203         String[] coord = mojoCoordinates(goal);
204         Xpp3Dom pomDom;
205         if (pom.startsWith("file:")) {
206             Path path = Paths.get(getBasedir()).resolve(pom.substring("file:".length()));
207             pomDom = Xpp3DomBuilder.build(ReaderFactory.newXmlReader(path.toFile()));
208         } else if (pom.startsWith("classpath:")) {
209             URL url = holder.getResource(pom.substring("classpath:".length()));
210             if (url == null) {
211                 throw new IllegalStateException("Unable to find pom on classpath: " + pom);
212             }
213             pomDom = Xpp3DomBuilder.build(ReaderFactory.newXmlReader(url.openStream()));
214         } else if (pom.contains("<project>")) {
215             pomDom = Xpp3DomBuilder.build(new StringReader(pom));
216         } else {
217             Path path = Paths.get(getBasedir()).resolve(pom);
218             pomDom = Xpp3DomBuilder.build(ReaderFactory.newXmlReader(path.toFile()));
219         }
220         Xpp3Dom pluginConfiguration = extractPluginConfiguration(coord[1], pomDom);
221         if (!mojoParameters.isEmpty()) {
222             List<Xpp3Dom> children = mojoParameters.stream()
223                     .map(mp -> {
224                         Xpp3Dom c = new Xpp3Dom(mp.name());
225                         c.setValue(mp.value());
226                         return c;
227                     })
228                     .collect(Collectors.toList());
229             Xpp3Dom config = new Xpp3Dom("configuration");
230             children.forEach(config::addChild);
231             pluginConfiguration = Xpp3Dom.mergeXpp3Dom(config, pluginConfiguration);
232         }
233         Mojo mojo = lookupMojo(coord, pluginConfiguration, descriptor);
234         return mojo;
235     }
236 
237     protected String[] mojoCoordinates(String goal) throws Exception {
238         if (goal.matches(".*:.*:.*:.*")) {
239             return goal.split(":");
240         } else {
241             Path pluginPom = Paths.get(getBasedir(), "pom.xml");
242             Xpp3Dom pluginPomDom = Xpp3DomBuilder.build(ReaderFactory.newXmlReader(pluginPom.toFile()));
243             String artifactId = pluginPomDom.getChild("artifactId").getValue();
244             String groupId = resolveFromRootThenParent(pluginPomDom, "groupId");
245             String version = resolveFromRootThenParent(pluginPomDom, "version");
246             return new String[] {groupId, artifactId, version, goal};
247         }
248     }
249 
250     /**
251      * lookup the mojo while we have all the relevent information
252      */
253     protected Mojo lookupMojo(String[] coord, Xpp3Dom pluginConfiguration, PluginDescriptor descriptor)
254             throws Exception {
255         // pluginkey = groupId : artifactId : version : goal
256         Mojo mojo = lookup(Mojo.class, coord[0] + ":" + coord[1] + ":" + coord[2] + ":" + coord[3]);
257         for (MojoDescriptor mojoDescriptor : descriptor.getMojos()) {
258             if (Objects.equals(
259                     mojoDescriptor.getImplementation(), mojo.getClass().getName())) {
260                 if (pluginConfiguration != null) {
261                     pluginConfiguration = finalizeConfig(pluginConfiguration, mojoDescriptor);
262                 }
263             }
264         }
265         if (pluginConfiguration != null) {
266             MavenSession session = getContainer().lookup(MavenSession.class);
267             MavenProject project;
268             try {
269                 project = getContainer().lookup(MavenProject.class);
270             } catch (ComponentLookupException e) {
271                 project = null;
272             }
273             MojoExecution mojoExecution;
274             try {
275                 mojoExecution = getContainer().lookup(MojoExecution.class);
276             } catch (ComponentLookupException e) {
277                 mojoExecution = null;
278             }
279             ExpressionEvaluator evaluator =
280                     new WrapEvaluator(getContainer(), new PluginParameterExpressionEvaluator(session, mojoExecution));
281             ComponentConfigurator configurator = new BasicComponentConfigurator();
282             configurator.configureComponent(
283                     mojo,
284                     new XmlPlexusConfiguration(pluginConfiguration),
285                     evaluator,
286                     getContainer().getContainerRealm());
287         }
288 
289         mojo.setLog(getContainer().lookup(Log.class));
290 
291         return mojo;
292     }
293 
294     private Xpp3Dom finalizeConfig(Xpp3Dom config, MojoDescriptor mojoDescriptor) {
295         List<Xpp3Dom> children = new ArrayList<>();
296         if (mojoDescriptor != null && mojoDescriptor.getParameters() != null) {
297             Xpp3Dom defaultConfiguration = MojoDescriptorCreator.convert(mojoDescriptor);
298             for (Parameter parameter : mojoDescriptor.getParameters()) {
299                 Xpp3Dom parameterConfiguration = config.getChild(parameter.getName());
300                 if (parameterConfiguration == null) {
301                     parameterConfiguration = config.getChild(parameter.getAlias());
302                 }
303                 Xpp3Dom parameterDefaults = defaultConfiguration.getChild(parameter.getName());
304                 parameterConfiguration = Xpp3Dom.mergeXpp3Dom(parameterConfiguration, parameterDefaults, Boolean.TRUE);
305                 if (parameterConfiguration != null) {
306                     if (isEmpty(parameterConfiguration.getAttribute("implementation"))
307                             && !isEmpty(parameter.getImplementation())) {
308                         parameterConfiguration.setAttribute("implementation", parameter.getImplementation());
309                     }
310                     children.add(parameterConfiguration);
311                 }
312             }
313         }
314         Xpp3Dom c = new Xpp3Dom("configuration");
315         children.forEach(c::addChild);
316         return c;
317     }
318 
319     private boolean isEmpty(String str) {
320         return str == null || str.isEmpty();
321     }
322 
323     private static Optional<Xpp3Dom> child(Xpp3Dom element, String name) {
324         return Optional.ofNullable(element.getChild(name));
325     }
326 
327     private static Stream<Xpp3Dom> children(Xpp3Dom element) {
328         return Stream.of(element.getChildren());
329     }
330 
331     public static Xpp3Dom extractPluginConfiguration(String artifactId, Xpp3Dom pomDom) throws Exception {
332         Xpp3Dom pluginConfigurationElement = child(pomDom, "build")
333                 .flatMap(buildElement -> child(buildElement, "plugins"))
334                 .map(MojoExtension::children)
335                 .orElseGet(Stream::empty)
336                 .filter(e -> e.getChild("artifactId").getValue().equals(artifactId))
337                 .findFirst()
338                 .flatMap(buildElement -> child(buildElement, "configuration"))
339                 .orElseThrow(
340                         () -> new ConfigurationException("Cannot find a configuration element for a plugin with an "
341                                 + "artifactId of " + artifactId + "."));
342         return pluginConfigurationElement;
343     }
344 
345     /**
346      * sometimes the parent element might contain the correct value so generalize that access
347      *
348      * TODO find out where this is probably done elsewhere
349      */
350     private static String resolveFromRootThenParent(Xpp3Dom pluginPomDom, String element) throws Exception {
351         return Optional.ofNullable(child(pluginPomDom, element).orElseGet(() -> child(pluginPomDom, "parent")
352                         .flatMap(e -> child(e, element))
353                         .orElse(null)))
354                 .map(Xpp3Dom::getValue)
355                 .orElseThrow(() -> new Exception("unable to determine " + element));
356     }
357 
358     /**
359      * Convenience method to obtain the value of a variable on a mojo that might not have a getter.
360      * <br>
361      * Note: the caller is responsible for casting to what the desired type is.
362      */
363     public static Object getVariableValueFromObject(Object object, String variable) throws IllegalAccessException {
364         Field field = ReflectionUtils.getFieldByNameIncludingSuperclasses(variable, object.getClass());
365         field.setAccessible(true);
366         return field.get(object);
367     }
368 
369     /**
370      * Convenience method to obtain all variables and values from the mojo (including its superclasses)
371      * <br>
372      * Note: the values in the map are of type Object so the caller is responsible for casting to desired types.
373      */
374     public static Map<String, Object> getVariablesAndValuesFromObject(Object object) throws IllegalAccessException {
375         return getVariablesAndValuesFromObject(object.getClass(), object);
376     }
377 
378     /**
379      * Convenience method to obtain all variables and values from the mojo (including its superclasses)
380      * <br>
381      * Note: the values in the map are of type Object so the caller is responsible for casting to desired types.
382      *
383      * @return map of variable names and values
384      */
385     public static Map<String, Object> getVariablesAndValuesFromObject(Class<?> clazz, Object object)
386             throws IllegalAccessException {
387         Map<String, Object> map = new HashMap<>();
388         Field[] fields = clazz.getDeclaredFields();
389         AccessibleObject.setAccessible(fields, true);
390         for (Field field : fields) {
391             map.put(field.getName(), field.get(object));
392         }
393         Class<?> superclass = clazz.getSuperclass();
394         if (!Object.class.equals(superclass)) {
395             map.putAll(getVariablesAndValuesFromObject(superclass, object));
396         }
397         return map;
398     }
399 
400     /**
401      * Convenience method to set values to variables in objects that don't have setters
402      */
403     public static void setVariableValueToObject(Object object, String variable, Object value)
404             throws IllegalAccessException {
405         Field field = ReflectionUtils.getFieldByNameIncludingSuperclasses(variable, object.getClass());
406         Objects.requireNonNull(field, "Field " + variable + " not found");
407         field.setAccessible(true);
408         field.set(object, value);
409     }
410 
411     static class WrapEvaluator implements TypeAwareExpressionEvaluator {
412 
413         private final PlexusContainer container;
414         private final TypeAwareExpressionEvaluator evaluator;
415 
416         WrapEvaluator(PlexusContainer container, TypeAwareExpressionEvaluator evaluator) {
417             this.container = container;
418             this.evaluator = evaluator;
419         }
420 
421         @Override
422         public Object evaluate(String expression) throws ExpressionEvaluationException {
423             return evaluate(expression, null);
424         }
425 
426         @Override
427         public Object evaluate(String expression, Class<?> type) throws ExpressionEvaluationException {
428             Object value = evaluator.evaluate(expression, type);
429             if (value == null) {
430                 String expr = stripTokens(expression);
431                 if (expr != null) {
432                     try {
433                         value = container.lookup(type, expr);
434                     } catch (ComponentLookupException e) {
435                         // nothing
436                     }
437                 }
438             }
439             return value;
440         }
441 
442         private String stripTokens(String expr) {
443             if (expr.startsWith("${") && expr.endsWith("}")) {
444                 return expr.substring(2, expr.length() - 1);
445             }
446             return null;
447         }
448 
449         @Override
450         public File alignToBaseDirectory(File path) {
451             return evaluator.alignToBaseDirectory(path);
452         }
453     }
454 }