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