/****************************************************************************** * Copyright (c) 2006, 2010 VMware Inc. * All rights reserved. This program and the accompanying materials * are made available under the terms of the Eclipse Public License v1.0 * and Apache License v2.0 which accompanies this distribution. * The Eclipse Public License is available at * http://www.eclipse.org/legal/epl-v10.html and the Apache License v2.0 * is available at http://www.opensource.org/licenses/apache2.0.php. * You may elect to redistribute this code under either of these licenses. * * Contributors: * VMware Inc. *****************************************************************************/ package org.eclipse.gemini.blueprint.test; import java.io.IOException; import java.security.AccessController; import java.security.PrivilegedAction; import java.util.ArrayList; import java.util.Collection; import java.util.Iterator; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Set; import java.util.jar.Attributes; import java.util.jar.Manifest; import org.eclipse.gemini.blueprint.test.internal.util.DependencyVisitor; import org.eclipse.gemini.blueprint.test.internal.util.jar.JarCreator; import org.eclipse.gemini.blueprint.util.OsgiStringUtils; import org.objectweb.asm.ClassReader; import org.osgi.framework.Bundle; import org.osgi.framework.BundleContext; import org.osgi.framework.Constants; import org.springframework.core.io.DefaultResourceLoader; import org.springframework.core.io.InputStreamResource; import org.springframework.core.io.Resource; import org.springframework.util.Assert; import org.springframework.util.ClassUtils; import org.springframework.util.CollectionUtils; import org.springframework.util.ObjectUtils; import org.springframework.util.StringUtils; /** * Enhanced subclass of {@link AbstractDependencyManagerTests} that facilitates * OSGi testing by creating at runtime, on the fly, a jar using the indicated * manifest and resource patterns (by default all files found under the root * path). * * <p/>The test class can automatically determine the imports required by the * test, create the OSGi bundle manifest and pack the test and its resources in * a jar that can be installed inside an OSGi platform. * * <p/>Additionally, a valid OSGi manifest is automatically created for the * resulting test if the user does not provide one. The classes present in the * archive are analyzed and based on their byte-code, the required * <code>Import-Package</code> entries (for packages not found in the bundle) * are created. * * Please see the reference documentation for an in-depth explanation and usage * examples. * * <p/>Note that in more complex scenarios, dedicated packaging tools (such as * ant scripts or maven2) should be used. * * <p/>It is recommend to extend {@link AbstractConfigurableBundleCreatorTests} * rather then this class as the former offers sensible defaults. * * @author Costin Leau * */ public abstract class AbstractOnTheFlyBundleCreatorTests extends AbstractDependencyManagerTests { private static final String META_INF_JAR_LOCATION = "/META-INF/MANIFEST.MF"; JarCreator jarCreator; /** field used for caching jar content */ private Map jarEntries; /** discovered manifest */ private Manifest manifest; public AbstractOnTheFlyBundleCreatorTests() { initializeJarCreator(); } public AbstractOnTheFlyBundleCreatorTests(String testName) { super(testName); initializeJarCreator(); } private void initializeJarCreator() { AccessController.doPrivileged(new PrivilegedAction() { public Object run() { jarCreator = new JarCreator(); return null; } }); } /** * Returns the root path used for locating the resources that will be packed * in the test bundle (the root path does not become part of the jar). * * <p/>By default, the current threads context ClassLoader is used to locate * the root of the classpath. Because unit tests will either be run from Maven * or an IDE this will resolve a test classes directory of sorts. * * <p/>For example when invoked from Maven <code>"file:./target/test-classes"</code> * will be resolved and used. * * @return root path given as a String */ protected String getRootPath() { return Thread.currentThread().getContextClassLoader().getResource(".").toString(); } /** * Returns the patterns used for identifying the resources added to the jar. * The patterns are added to the root path when performing the search. By * default, the pattern is <code>**/*</code>. * * <p/>In large test environments, performance can be improved by limiting * the resource added to the bundle by selecting only certain packages or * classes. This results in a small test bundle which is faster to create, * deploy and install. * * @return the patterns identifying the resources added to the jar */ protected String[] getBundleContentPattern() { return new String[] { JarCreator.EVERYTHING_PATTERN }; } /** * Returns the location (in Spring resource style) of the manifest location * to be used. By default <code>null</code> is returned, indicating that * the manifest should be picked up from the bundle content (if it's * available) or be automatically created based on the test class imports. * * @return the manifest location * @see #getManifest() * @see #createDefaultManifest() */ protected String getManifestLocation() { return null; } /** * Returns the current test bundle manifest. The method tries to read the * manifest from the given location; in case the location is * <code>null</code> (default), it will search for * <code>META-INF/MANIFEST.MF</code> file in jar content (as specified * through the patterns) and, if it cannot find the file, * <em>automatically</em> create a <code>Manifest</code> object * containing default entries. * * <p/> Subclasses can override this method to enhance the returned * Manifest. * * @return Manifest used for this test suite. * * @see #createDefaultManifest() */ protected Manifest getManifest() { // return cached manifest if (manifest != null) return manifest; String manifestLocation = getManifestLocation(); if (StringUtils.hasText(manifestLocation)) { logger.info("Using Manifest from specified location=[" + getManifestLocation() + "]"); DefaultResourceLoader loader = new DefaultResourceLoader(); manifest = createManifestFrom(loader.getResource(manifestLocation)); } else { // set root path jarCreator.setRootPath(getRootPath()); // add the content pattern jarCreator.setContentPattern(getBundleContentPattern()); // see if the manifest already exists in the classpath // to resolve the patterns jarEntries = jarCreator.resolveContent(); for (Iterator iterator = jarEntries.entrySet().iterator(); iterator.hasNext();) { Map.Entry entry = (Map.Entry) iterator.next(); if (META_INF_JAR_LOCATION.equals(entry.getKey())) { logger.info("Using Manifest from the test bundle content=[/META-INF/MANIFEST.MF]"); manifest = createManifestFrom((Resource) entry.getValue()); } } // fallback to default manifest creation if (manifest == null) { logger.info("Automatically creating Manifest for the test bundle"); manifest = createDefaultManifest(); } } return manifest; } /** * Indicates if the automatic manifest creation should consider only the * test class (<code>true</code>) or all classes included in the test * bundle(<code>false</code>). The latter should be used when the test * bundle contains additional classes that help with the test case. * * <p/> By default, this method returns <code>true</code>, meaning that * only the test class will be searched for dependencies. * * @return true if only the test hierarchy is searched for dependencies or * false if all classes discovered in the test archive need to be * parsed. */ protected boolean createManifestOnlyFromTestClass() { return true; } private Manifest createManifestFrom(Resource resource) { Assert.notNull(resource, "unable to create manifest for empty resources"); try { return new Manifest(resource.getInputStream()); } catch (IOException ex) { throw (RuntimeException) new IllegalArgumentException("cannot create manifest from " + resource).initCause(ex); } } /** * Creates the default manifest in case none if found on the disk. By * default, the imports are synthetised based on the test class bytecode. * * @return default manifest for the jar created on the fly */ protected Manifest createDefaultManifest() { Manifest manifest = new Manifest(); Attributes attrs = manifest.getMainAttributes(); // manifest versions attrs.put(Attributes.Name.MANIFEST_VERSION, "1.0"); attrs.putValue(Constants.BUNDLE_MANIFESTVERSION, "2"); String description = getName() + "-" + getClass().getName(); // name/description attrs.putValue(Constants.BUNDLE_NAME, "TestBundle-" + description); attrs.putValue(Constants.BUNDLE_SYMBOLICNAME, "TestBundle-" + description); attrs.putValue(Constants.BUNDLE_DESCRIPTION, "on-the-fly test bundle"); // activator attrs.putValue(Constants.BUNDLE_ACTIVATOR, JUnitTestActivator.class.getName()); // add Import-Package entry addImportPackage(manifest); if (logger.isDebugEnabled()) logger.debug("Created manifest:" + manifest.getMainAttributes().entrySet()); return manifest; } private void addImportPackage(Manifest manifest) { String[] rawImports = determineImports(); boolean trace = logger.isTraceEnabled(); if (trace) logger.trace("Discovered raw imports " + ObjectUtils.nullSafeToString(rawImports)); Collection specialImportsOut = eliminateSpecialPackages(rawImports); Collection imports = eliminatePackagesAvailableInTheJar(specialImportsOut); if (trace) logger.trace("Filtered imports are " + imports); manifest.getMainAttributes().putValue(Constants.IMPORT_PACKAGE, StringUtils.collectionToCommaDelimitedString(imports)); } /** * Eliminate 'special' packages (java.*, test framework internal and the * class declaring package) * * @param rawImports * @return */ private Collection eliminateSpecialPackages(String[] rawImports) { String currentPckg = ClassUtils.classPackageAsResourcePath(getClass()).replace('/', '.'); Set filteredImports = new LinkedHashSet(rawImports.length); Set eliminatedImports = new LinkedHashSet(4); for (int i = 0; i < rawImports.length; i++) { String pckg = rawImports[i]; if (!(pckg.startsWith("java.") || pckg.startsWith("org.eclipse.gemini.blueprint.test.internal") || pckg.equals(currentPckg))) filteredImports.add(pckg); else eliminatedImports.add(pckg); } if (!eliminatedImports.isEmpty() && logger.isTraceEnabled()) logger.trace("Eliminated special packages " + eliminatedImports); return filteredImports; } /** * Eliminates imports for packages already included in the bundle. Works * only if the jar content is known (variable 'jarEntries' set). * * @param imports * @return */ private Collection eliminatePackagesAvailableInTheJar(Collection imports) { // no jar entry present, bail out. if (jarEntries == null || jarEntries.isEmpty()) return imports; Set filteredImports = new LinkedHashSet(imports.size()); Collection eliminatedImports = new LinkedHashSet(2); Collection jarPackages = jarCreator.getContainedPackages(); for (Iterator iterator = imports.iterator(); iterator.hasNext();) { String pckg = (String) iterator.next(); if (jarPackages.contains(pckg)) eliminatedImports.add(pckg); else filteredImports.add(pckg); } if (!eliminatedImports.isEmpty() && logger.isTraceEnabled()) logger.trace("Eliminated packages already present in the bundle " + eliminatedImports); return filteredImports; } /** * Determine imports for the given bundle. Based on the user settings, this * method will consider only the the test hierarchy until the testing * framework is found or all classes available inside the test bundle. <p/> * Note that split packages are not supported. * * @return */ private String[] determineImports() { boolean useTestClassOnly = false; // no jar entry present, bail out. if (jarEntries == null || jarEntries.isEmpty()) { logger.debug("No test jar content detected, generating bundle imports from the test class"); useTestClassOnly = true; } else if (createManifestOnlyFromTestClass()) { logger.info("Using the test class for generating bundle imports"); useTestClassOnly = true; } else logger.info("Using all classes in the jar for the generation of bundle imports"); // className, class resource Map entries; if (useTestClassOnly) { entries = new LinkedHashMap(4); // get current class (test class that bootstraps the OSGi infrastructure) Class<?> clazz = getClass(); String clazzPackage = null; String endPackage = AbstractOnTheFlyBundleCreatorTests.class.getPackage().getName(); do { // consider inner classes as well List classes = new ArrayList(4); classes.add(clazz); CollectionUtils.mergeArrayIntoCollection(clazz.getDeclaredClasses(), classes); for (Iterator iterator = classes.iterator(); iterator.hasNext();) { Class<?> classToInspect = (Class) iterator.next(); Package pkg = classToInspect.getPackage(); if (pkg != null) { clazzPackage = pkg.getName(); String classFile = ClassUtils.getClassFileName(classToInspect); entries.put(classToInspect.getName().replace('.', '/').concat(ClassUtils.CLASS_FILE_SUFFIX), new InputStreamResource(classToInspect.getResourceAsStream(classFile))); } // handle default package else { logger.warn("Could not find package for class " + classToInspect + "; ignoring..."); } } clazz = clazz.getSuperclass(); } while (!endPackage.equals(clazzPackage)); } else entries = jarEntries; return determineImportsFor(entries); } private String[] determineImportsFor(Map entries) { // get contained packages to do matching on the test hierarchy Collection containedPackages = jarCreator.getContainedPackages(); Set cumulatedPackages = new LinkedHashSet(); // make sure the collection package is valid boolean validPackageCollection = !containedPackages.isEmpty(); boolean trace = logger.isTraceEnabled(); for (Iterator iterator = entries.entrySet().iterator(); iterator.hasNext();) { Map.Entry entry = (Map.Entry) iterator.next(); String resourceName = (String) entry.getKey(); // filter out the test hierarchy if (resourceName.endsWith(ClassUtils.CLASS_FILE_SUFFIX)) { if (trace) logger.trace("Analyze imports for test bundle resource " + resourceName); String classFileName = StringUtils.getFilename(resourceName); String className = classFileName.substring(0, classFileName.length() - ClassUtils.CLASS_FILE_SUFFIX.length()); String classPkg = resourceName.substring(0, resourceName.length() - classFileName.length()).replace( '/', '.'); if (classPkg.startsWith(".")) classPkg = classPkg.substring(1); if (classPkg.endsWith(".")) classPkg = classPkg.substring(0, classPkg.length() - 1); // if we don't have the package, add it if (validPackageCollection && StringUtils.hasText(classPkg) && !containedPackages.contains(classPkg)) { logger.trace("Package [" + classPkg + "] is NOT part of the test archive; adding an import for it"); cumulatedPackages.add(classPkg); } // otherwise parse the class byte-code else { if (trace) logger.trace("Package [" + classPkg + "] is part of the test archive; parsing " + className + " bytecode to determine imports..."); cumulatedPackages.addAll(determineImportsForClass(className, (Resource) entry.getValue())); } } } return (String[]) cumulatedPackages.toArray(new String[cumulatedPackages.size()]); } /** * Determine imports for a class given as a String resource. This method * doesn't do any search for the enclosing/inner classes as it considers * that these should be handled at a higher level. * * The returned set contains the packages in string format (i.e. java.io) * * @param className * @param resource * @return */ private Set determineImportsForClass(String className, Resource resource) { Assert.notNull(resource, "a not-null class is required"); DependencyVisitor visitor = new DependencyVisitor(); boolean trace = logger.isTraceEnabled(); ClassReader reader; try { if (trace) logger.trace("Visiting class " + className); reader = new ClassReader(resource.getInputStream()); } catch (Exception ex) { throw (RuntimeException) new IllegalArgumentException("Cannot read class " + className).initCause(ex); } reader.accept(visitor, false); // convert from / to . format Set originalPackages = visitor.getPackages(); Set pkgs = new LinkedHashSet(originalPackages.size()); for (Iterator iterator = originalPackages.iterator(); iterator.hasNext();) { String pkg = (String) iterator.next(); pkgs.add(pkg.replace('/', '.')); } return pkgs; } protected void postProcessBundleContext(BundleContext context) throws Exception { logger.debug("Post processing: creating test bundle"); Resource jar; Manifest mf = getManifest(); // if the jar content hasn't been discovered yet (while creating the manifest) // do so now if (jarEntries == null) { // set root path jarCreator.setRootPath(getRootPath()); // add the content pattern jarCreator.setContentPattern(getBundleContentPattern()); // use jar creator for pattern discovery jar = jarCreator.createJar(mf); } // otherwise use the cached resources else { jar = jarCreator.createJar(mf, jarEntries); } try { installAndStartBundle(context, jar); } catch (Exception e) { IllegalStateException ise = new IllegalStateException( "Unable to dynamically start generated unit test bundle"); ise.initCause(e); throw ise; } // now do the delegation super.postProcessBundleContext(context); } private void installAndStartBundle(BundleContext context, Resource resource) throws Exception { // install & start Bundle bundle = context.installBundle("[onTheFly-test-bundle]" + ClassUtils.getShortName(getClass()) + "[" + hashCode() + "]", resource.getInputStream()); String bundleString = OsgiStringUtils.nullSafeNameAndSymName(bundle); boolean debug = logger.isDebugEnabled(); if (debug) logger.debug("Test bundle [" + bundleString + "] successfully installed"); bundle.start(); if (debug) logger.debug("Test bundle [" + bundleString + "] successfully started"); } }